Compare commits
57 Commits
feat/dashb
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f652ccba90 | ||
|
|
782e6797d8 | ||
|
|
09ff7b27a5 | ||
|
|
fe994205f2 | ||
|
|
cf80a8b681 | ||
|
|
5bdd6a2225 | ||
|
|
93678e59bc | ||
|
|
14c30dad03 | ||
|
|
08d49b9b67 | ||
|
|
036ae5c6f6 | ||
|
|
4c45805390 | ||
|
|
fc943afb36 | ||
|
|
25ae50dcd6 | ||
|
|
d1422efe87 | ||
|
|
45da5d075d | ||
|
|
bdc54da4ad | ||
|
|
cb6dc5e433 | ||
|
|
3add610e85 | ||
|
|
183a22c475 | ||
|
|
f2297d62f2 | ||
|
|
6b994147c3 | ||
|
|
a6d9e203c2 | ||
|
|
f652c19b24 | ||
|
|
76ce86fb41 | ||
|
|
5a22f7dbb6 | ||
|
|
5b8809b962 | ||
|
|
cdf982ed39 | ||
|
|
aac83a084c | ||
|
|
5f9613290d | ||
|
|
c92cd230d5 | ||
|
|
bcbfdd3c91 | ||
|
|
3c21093e66 | ||
|
|
e4e0a19873 | ||
|
|
5b0503abf5 | ||
|
|
cc8aab7ee1 | ||
|
|
2c1f7d093f | ||
|
|
11a07692ad | ||
|
|
05754c9d5b | ||
|
|
e26da88f22 | ||
|
|
b703b27676 | ||
|
|
5dffd96187 | ||
|
|
9ff1f2b37b | ||
|
|
23ad9f2824 | ||
|
|
b90cb829c9 | ||
|
|
69d9174314 | ||
|
|
c350ecbb3c | ||
|
|
06098c7013 | ||
|
|
709321383c | ||
|
|
7506466c38 | ||
|
|
c84455a11b | ||
|
|
2e7fff135c | ||
|
|
a1a8e3ee9c | ||
|
|
72ab5d8baa | ||
|
|
fc715c6022 | ||
|
|
32a00493f8 | ||
|
|
829d4c6b6c | ||
|
|
db2386b8a9 |
@@ -38,15 +38,17 @@ steps:
|
||||
- |
|
||||
cat > .env << 'EOF'
|
||||
APP_NAME="Cannabrands Hub"
|
||||
APP_ENV=production
|
||||
APP_ENV=development
|
||||
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
EOF
|
||||
# Restore composer cache if available
|
||||
- mkdir -p /root/.composer/cache
|
||||
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
|
||||
# Clean vendor to avoid cached state missing dev deps
|
||||
- rm -rf vendor
|
||||
# Clean vendor and bootstrap cache to force fresh install
|
||||
- rm -rf vendor bootstrap/cache/*.php
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
# Verify test command is available
|
||||
- php artisan list test | head -5
|
||||
# Save cache for next build
|
||||
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
|
||||
- echo "✅ Composer done"
|
||||
@@ -87,7 +89,7 @@ steps:
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Split tests: Unit tests (fast, no DB)
|
||||
# Split tests: Unit tests (with DB - some unit tests use factories)
|
||||
tests-unit:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
@@ -99,8 +101,12 @@ steps:
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: ":memory:"
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 10.100.6.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
commands:
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
@@ -120,7 +126,7 @@ steps:
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 10.100.6.50
|
||||
DB_HOST: 10.100.7.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
|
||||
144
app/Console/Commands/MigrateDbaData.php
Normal file
144
app/Console/Commands/MigrateDbaData.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Migrate existing business DBA data to the new business_dbas table.
|
||||
*
|
||||
* This command creates DBA records from existing business fields:
|
||||
* - dba_name
|
||||
* - invoice_payable_company_name, invoice_payable_address, etc.
|
||||
* - ap_contact_* fields
|
||||
* - primary_contact_* fields
|
||||
*/
|
||||
class MigrateDbaData extends Command
|
||||
{
|
||||
protected $signature = 'dba:migrate
|
||||
{--dry-run : Show what would be created without actually creating records}
|
||||
{--business= : Migrate only a specific business by ID or slug}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('DBA Data Migration');
|
||||
$this->line('==================');
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$specificBusiness = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No records will be created');
|
||||
}
|
||||
|
||||
// Build query
|
||||
$query = Business::query()
|
||||
->whereNotNull('dba_name')
|
||||
->where('dba_name', '!=', '');
|
||||
|
||||
if ($specificBusiness) {
|
||||
$query->where(function ($q) use ($specificBusiness) {
|
||||
$q->where('id', $specificBusiness)
|
||||
->orWhere('slug', $specificBusiness);
|
||||
});
|
||||
}
|
||||
|
||||
$businesses = $query->get();
|
||||
$this->info("Found {$businesses->count()} businesses with dba_name set.");
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->info('No businesses to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
|
||||
$businesses->map(fn ($b) => [
|
||||
$b->id,
|
||||
\Illuminate\Support\Str::limit($b->name, 30),
|
||||
\Illuminate\Support\Str::limit($b->dba_name, 30),
|
||||
$b->invoice_payable_address ? 'Yes' : 'No',
|
||||
$b->dbas()->exists() ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
if (! $dryRun && ! $this->option('force')) {
|
||||
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
|
||||
$this->info('Aborted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
// Skip if business already has DBAs
|
||||
if ($business->dbas()->exists()) {
|
||||
$this->line(" Skipping {$business->name} - already has DBAs");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
|
||||
$created++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create DBA from existing business fields
|
||||
$dba = BusinessDba::create([
|
||||
'business_id' => $business->id,
|
||||
'trade_name' => $business->dba_name,
|
||||
|
||||
// Address - prefer invoice_payable fields, fall back to physical
|
||||
'address' => $business->invoice_payable_address ?: $business->physical_address,
|
||||
'city' => $business->invoice_payable_city ?: $business->physical_city,
|
||||
'state' => $business->invoice_payable_state ?: $business->physical_state,
|
||||
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
|
||||
|
||||
// License
|
||||
'license_number' => $business->license_number,
|
||||
'license_type' => $business->license_type,
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
|
||||
'primary_contact_email' => $business->primary_contact_email,
|
||||
'primary_contact_phone' => $business->primary_contact_phone,
|
||||
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
|
||||
'ap_contact_email' => $business->ap_contact_email,
|
||||
'ap_contact_phone' => $business->ap_contact_phone,
|
||||
|
||||
// Invoice Settings
|
||||
'invoice_footer' => $business->order_invoice_footer,
|
||||
|
||||
// Status
|
||||
'is_default' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Summary: {$created} created, {$skipped} skipped");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Run without --dry-run to actually create records.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,20 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// BANNER ADS
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Update banner ad statuses (activate scheduled, expire ended) - every minute
|
||||
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Rollup daily banner ad stats - daily at 2 AM
|
||||
$schedule->job(new \App\Jobs\RollupBannerAdStats)
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// HOUSEKEEPING & MAINTENANCE
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
41
app/Enums/BannerAdStatus.php
Normal file
41
app/Enums/BannerAdStatus.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BannerAdStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case ACTIVE = 'active';
|
||||
case SCHEDULED = 'scheduled';
|
||||
case PAUSED = 'paused';
|
||||
case EXPIRED = 'expired';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'Draft',
|
||||
self::ACTIVE => 'Active',
|
||||
self::SCHEDULED => 'Scheduled',
|
||||
self::PAUSED => 'Paused',
|
||||
self::EXPIRED => 'Expired',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'gray',
|
||||
self::ACTIVE => 'success',
|
||||
self::SCHEDULED => 'info',
|
||||
self::PAUSED => 'warning',
|
||||
self::EXPIRED => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $status) => [
|
||||
$status->value => $status->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
51
app/Enums/BannerAdZone.php
Normal file
51
app/Enums/BannerAdZone.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BannerAdZone: string
|
||||
{
|
||||
case MARKETPLACE_HERO = 'marketplace_hero';
|
||||
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
|
||||
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
|
||||
case MARKETPLACE_INLINE = 'marketplace_inline';
|
||||
case BRAND_PAGE_BANNER = 'brand_page_banner';
|
||||
case DEALS_PAGE_HERO = 'deals_page_hero';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
|
||||
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
|
||||
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
|
||||
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
|
||||
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
|
||||
self::DEALS_PAGE_HERO => 'Deals Page Hero',
|
||||
};
|
||||
}
|
||||
|
||||
public function dimensions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
|
||||
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
|
||||
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
|
||||
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
|
||||
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
|
||||
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
public static function optionsSimple(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||
$zone->value => $zone->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Crm\CrmDeal;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DealStageChanged implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmDeal $deal,
|
||||
public string $previousStage,
|
||||
public string $newStage
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("dashboard.{$this->deal->business_id}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'deal.stage_changed';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'deal' => [
|
||||
'id' => $this->deal->id,
|
||||
'hashid' => $this->deal->hashid,
|
||||
'name' => $this->deal->name,
|
||||
'value' => $this->deal->value / 100,
|
||||
'previous_stage' => $this->previousStage,
|
||||
'new_stage' => $this->newStage,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Order;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NewOrderReceived implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order,
|
||||
public int $sellerBusinessId
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("dashboard.{$this->sellerBusinessId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'order.new';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'order' => [
|
||||
'id' => $this->order->id,
|
||||
'order_number' => $this->order->order_number,
|
||||
'business_name' => $this->order->business?->name ?? 'Unknown',
|
||||
'total' => $this->order->total / 100,
|
||||
'status' => $this->order->status,
|
||||
'created_at' => $this->order->created_at->toIso8601String(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
318
app/Filament/Resources/BannerAdResource.php
Normal file
318
app/Filament/Resources/BannerAdResource.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\BannerAdStatus;
|
||||
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\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;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use UnitEnum;
|
||||
|
||||
class BannerAdResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BannerAd::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Marketing';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
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 () {
|
||||
// Handle case where migrations haven't been run yet
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
|
||||
|
||||
return $count ?: null;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Basic Information')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Internal Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Internal reference name (not shown to users)'),
|
||||
|
||||
Select::make('zone')
|
||||
->label('Ad Zone')
|
||||
->options(BannerAdZone::options())
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(fn ($state, $set) => $state
|
||||
? $set('zone_info', BannerAdZone::from($state)->dimensions()['display'])
|
||||
: $set('zone_info', null)),
|
||||
|
||||
Placeholder::make('zone_info')
|
||||
->label('Recommended Dimensions')
|
||||
->content(fn ($get) => $get('zone')
|
||||
? BannerAdZone::from($get('zone'))->dimensions()['display']
|
||||
: 'Select a zone'),
|
||||
|
||||
Select::make('status')
|
||||
->options(BannerAdStatus::options())
|
||||
->default('draft')
|
||||
->required(),
|
||||
|
||||
Select::make('brand_id')
|
||||
->label('Brand (Optional)')
|
||||
->relationship('brand', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Leave empty for platform-wide ads'),
|
||||
]),
|
||||
|
||||
Section::make('Creative Content')
|
||||
->columns(2)
|
||||
->schema([
|
||||
FileUpload::make('image_path')
|
||||
->label('Banner Image')
|
||||
->image()
|
||||
->required()
|
||||
->disk('minio')
|
||||
->directory('banner-ads')
|
||||
->visibility('public')
|
||||
->maxSize(5120)
|
||||
->helperText('Upload banner image at recommended dimensions')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('image_alt')
|
||||
->label('Alt Text')
|
||||
->maxLength(255)
|
||||
->helperText('Accessibility description'),
|
||||
|
||||
TextInput::make('headline')
|
||||
->maxLength(100)
|
||||
->helperText('Optional overlay headline'),
|
||||
|
||||
Textarea::make('description')
|
||||
->maxLength(200)
|
||||
->helperText('Optional overlay description'),
|
||||
|
||||
TextInput::make('cta_text')
|
||||
->label('Button Text')
|
||||
->maxLength(50)
|
||||
->placeholder('Shop Now')
|
||||
->helperText('Call-to-action button text'),
|
||||
|
||||
TextInput::make('cta_url')
|
||||
->label('Destination URL')
|
||||
->required()
|
||||
->url()
|
||||
->maxLength(500)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make('Scheduling')
|
||||
->columns(2)
|
||||
->schema([
|
||||
DateTimePicker::make('starts_at')
|
||||
->label('Start Date')
|
||||
->helperText('Leave empty to start immediately'),
|
||||
|
||||
DateTimePicker::make('ends_at')
|
||||
->label('End Date')
|
||||
->helperText('Leave empty to run indefinitely'),
|
||||
]),
|
||||
|
||||
Section::make('Targeting & Priority')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('is_platform_wide')
|
||||
->label('Platform Wide')
|
||||
->default(true)
|
||||
->helperText('Show to all users'),
|
||||
|
||||
Select::make('target_business_types')
|
||||
->label('Target Business Types')
|
||||
->multiple()
|
||||
->options([
|
||||
'buyer' => 'Buyers (Dispensaries)',
|
||||
'seller' => 'Sellers (Brands)',
|
||||
])
|
||||
->helperText('Leave empty for all types'),
|
||||
|
||||
TextInput::make('priority')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Higher = shown first (0-100)'),
|
||||
|
||||
TextInput::make('weight')
|
||||
->numeric()
|
||||
->default(100)
|
||||
->minValue(1)
|
||||
->maxValue(1000)
|
||||
->helperText('Weight for random rotation (1-1000)'),
|
||||
]),
|
||||
|
||||
Section::make('Analytics')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Placeholder::make('impressions_display')
|
||||
->label('Impressions')
|
||||
->content(fn (?BannerAd $record) => number_format($record?->impressions ?? 0)),
|
||||
|
||||
Placeholder::make('clicks_display')
|
||||
->label('Clicks')
|
||||
->content(fn (?BannerAd $record) => number_format($record?->clicks ?? 0)),
|
||||
|
||||
Placeholder::make('ctr_display')
|
||||
->label('CTR')
|
||||
->content(fn (?BannerAd $record) => ($record?->click_through_rate ?? 0).'%'),
|
||||
])
|
||||
->hiddenOn('create'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('image_path')
|
||||
->label('Preview')
|
||||
->disk('minio')
|
||||
->width(120)
|
||||
->height(60),
|
||||
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
Tables\Columns\TextColumn::make('zone')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => $state instanceof BannerAdZone
|
||||
? $state->label()
|
||||
: BannerAdZone::tryFrom($state)?->label() ?? $state),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn ($state) => $state instanceof BannerAdStatus
|
||||
? $state->color()
|
||||
: BannerAdStatus::tryFrom($state)?->color() ?? 'gray'),
|
||||
|
||||
Tables\Columns\TextColumn::make('impressions')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('clicks')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('click_through_rate')
|
||||
->label('CTR')
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('starts_at')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('ends_at')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(BannerAdStatus::options()),
|
||||
Tables\Filters\SelectFilter::make('zone')
|
||||
->options(BannerAdZone::optionsSimple()),
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBannerAds::route('/'),
|
||||
'create' => Pages\CreateBannerAd::route('/create'),
|
||||
'view' => Pages\ViewBannerAd::route('/{record}'),
|
||||
'edit' => Pages\EditBannerAd::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBannerAd extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['created_by_user_id'] = auth()->id();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use App\Services\BannerAdService;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBannerAd extends EditRecord
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\ForceDeleteAction::make(),
|
||||
Actions\RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Clear caches when banner ad is updated
|
||||
app(BannerAdService::class)->clearAllCaches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBannerAds extends ListRecords
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBannerAd extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -699,6 +699,11 @@ class BusinessResource extends Resource
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
|
||||
Toggle::make('ping_pong_enabled')
|
||||
->label('Ping Pong Order Flow')
|
||||
->helperText('When enabled, buyers and sellers can send order details back and forth during the order process. Shows order progress stages and enables collaborative order editing.')
|
||||
->default(false),
|
||||
]),
|
||||
|
||||
// ===== SUITE ASSIGNMENT SECTION =====
|
||||
@@ -2082,6 +2087,7 @@ class BusinessResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BusinessResource\RelationManagers\DbasRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessResource\RelationManagers;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Grid;
|
||||
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\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DbasRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'dbas';
|
||||
|
||||
protected static ?string $title = 'Trade Names (DBAs)';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'trade_name';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Basic Information')
|
||||
->schema([
|
||||
TextInput::make('trade_name')
|
||||
->label('Trade Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Auto-generated from trade name'),
|
||||
Toggle::make('is_default')
|
||||
->label('Default DBA')
|
||||
->helperText('Use for new invoices by default'),
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Address')
|
||||
->schema([
|
||||
TextInput::make('address')
|
||||
->label('Street Address')
|
||||
->maxLength(255),
|
||||
TextInput::make('address_line_2')
|
||||
->label('Address Line 2')
|
||||
->maxLength(255),
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('city')
|
||||
->maxLength(255),
|
||||
TextInput::make('state')
|
||||
->maxLength(2)
|
||||
->extraAttributes(['class' => 'uppercase']),
|
||||
TextInput::make('zip')
|
||||
->label('ZIP Code')
|
||||
->maxLength(10),
|
||||
]),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
Section::make('License Information')
|
||||
->schema([
|
||||
TextInput::make('license_number')
|
||||
->maxLength(255),
|
||||
TextInput::make('license_type')
|
||||
->maxLength(255),
|
||||
DatePicker::make('license_expiration')
|
||||
->label('Expiration Date'),
|
||||
])
|
||||
->columns(3)
|
||||
->collapsible(),
|
||||
|
||||
Section::make('Banking Information')
|
||||
->description('Sensitive data is encrypted at rest.')
|
||||
->schema([
|
||||
TextInput::make('bank_name')
|
||||
->maxLength(255),
|
||||
TextInput::make('bank_account_name')
|
||||
->maxLength(255),
|
||||
TextInput::make('bank_routing_number')
|
||||
->maxLength(50)
|
||||
->password()
|
||||
->revealable(),
|
||||
TextInput::make('bank_account_number')
|
||||
->maxLength(50)
|
||||
->password()
|
||||
->revealable(),
|
||||
Select::make('bank_account_type')
|
||||
->options([
|
||||
'checking' => 'Checking',
|
||||
'savings' => 'Savings',
|
||||
]),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Tax Information')
|
||||
->description('Sensitive data is encrypted at rest.')
|
||||
->schema([
|
||||
TextInput::make('tax_id')
|
||||
->label('Tax ID')
|
||||
->maxLength(50)
|
||||
->password()
|
||||
->revealable(),
|
||||
Select::make('tax_id_type')
|
||||
->label('Tax ID Type')
|
||||
->options([
|
||||
'ein' => 'EIN',
|
||||
'ssn' => 'SSN',
|
||||
]),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Contacts')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Section::make('Primary Contact')
|
||||
->schema([
|
||||
TextInput::make('primary_contact_name')
|
||||
->label('Name')
|
||||
->maxLength(255),
|
||||
TextInput::make('primary_contact_email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('primary_contact_phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(50),
|
||||
]),
|
||||
Section::make('AP Contact')
|
||||
->schema([
|
||||
TextInput::make('ap_contact_name')
|
||||
->label('Name')
|
||||
->maxLength(255),
|
||||
TextInput::make('ap_contact_email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('ap_contact_phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(50),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Invoice Settings')
|
||||
->schema([
|
||||
TextInput::make('payment_terms')
|
||||
->maxLength(50)
|
||||
->placeholder('Net 30'),
|
||||
TextInput::make('invoice_prefix')
|
||||
->maxLength(10)
|
||||
->placeholder('INV-'),
|
||||
Textarea::make('payment_instructions')
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
Textarea::make('invoice_footer')
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('trade_name')
|
||||
->label('Trade Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('city')
|
||||
->label('Location')
|
||||
->formatStateUsing(fn ($record) => $record->city && $record->state
|
||||
? "{$record->city}, {$record->state}"
|
||||
: ($record->city ?? $record->state ?? '-'))
|
||||
->sortable(),
|
||||
TextColumn::make('license_number')
|
||||
->label('License')
|
||||
->limit(15)
|
||||
->tooltip(fn ($state) => $state),
|
||||
IconColumn::make('is_default')
|
||||
->label('Default')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-star')
|
||||
->falseIcon('heroicon-o-minus')
|
||||
->trueColor('warning'),
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('is_default', 'desc')
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->emptyStateHeading('No Trade Names')
|
||||
->emptyStateDescription('Add a DBA to manage different trade names for invoices and licenses.')
|
||||
->emptyStateIcon('heroicon-o-building-office-2');
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/BannerAdController.php
Normal file
96
app/Http/Controllers/BannerAdController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BannerAd;
|
||||
use App\Services\BannerAdService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class BannerAdController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BannerAdService $bannerAdService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle click tracking and redirect
|
||||
* URL: /ads/click/{bannerAd}
|
||||
*/
|
||||
public function click(Request $request, BannerAd $bannerAd)
|
||||
{
|
||||
$this->bannerAdService->recordClick($bannerAd, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => session()->getId(),
|
||||
'page_url' => $request->header('referer'),
|
||||
]);
|
||||
|
||||
return redirect()->away($bannerAd->cta_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track impression via AJAX (for lazy-loaded ads)
|
||||
* URL: POST /ads/impression/{bannerAd}
|
||||
*/
|
||||
public function impression(Request $request, BannerAd $bannerAd)
|
||||
{
|
||||
$this->bannerAdService->recordImpression($bannerAd, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => session()->getId(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve banner ad image at specific width
|
||||
* URL: /images/banner-ad/{bannerAd}/{width?}
|
||||
*/
|
||||
public function image(BannerAd $bannerAd, ?int $width = null)
|
||||
{
|
||||
if (! $bannerAd->image_path || ! Storage::exists($bannerAd->image_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Return original if no width specified
|
||||
if (! $width) {
|
||||
$contents = Storage::get($bannerAd->image_path);
|
||||
$mimeType = Storage::mimeType($bannerAd->image_path);
|
||||
|
||||
return response($contents)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
// Generate and cache resized version
|
||||
$ext = pathinfo($bannerAd->image_path, PATHINFO_EXTENSION);
|
||||
$thumbnailName = "banner-ad-{$bannerAd->id}-{$width}w.{$ext}";
|
||||
$thumbnailPath = "banner-ads/cache/{$thumbnailName}";
|
||||
|
||||
if (! Storage::disk('local')->exists($thumbnailPath)) {
|
||||
$originalContents = Storage::get($bannerAd->image_path);
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read($originalContents);
|
||||
$image->scale(width: $width);
|
||||
|
||||
if (! Storage::disk('local')->exists('banner-ads/cache')) {
|
||||
Storage::disk('local')->makeDirectory('banner-ads/cache');
|
||||
}
|
||||
|
||||
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||
Storage::disk('local')->put($thumbnailPath, $encoded);
|
||||
}
|
||||
|
||||
$mimeType = $ext === 'png' ? 'image/png' : 'image/jpeg';
|
||||
|
||||
return response()->file(
|
||||
storage_path("app/private/{$thumbnailPath}"),
|
||||
['Content-Type' => $mimeType, 'Cache-Control' => 'public, max-age=86400']
|
||||
);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\Cannaiq\MarketingIntelligenceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BuyAgainController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$tab = $request->get('tab', 'favorites'); // 'favorites' or 'history'
|
||||
|
||||
if ($tab === 'favorites') {
|
||||
$brands = $this->getFavoriteBrands($business);
|
||||
} else {
|
||||
$brands = $this->getPurchaseHistory($business);
|
||||
}
|
||||
|
||||
// Optional: Enrich with CannaIQ inventory data if business has it
|
||||
$storeMetrics = null;
|
||||
if ($business->cannaiq_store_id) {
|
||||
$storeMetrics = $this->getStoreInventory($business, $brands);
|
||||
}
|
||||
|
||||
return view('buyer.buy-again.index', compact('business', 'brands', 'tab', 'storeMetrics'));
|
||||
}
|
||||
|
||||
private function getFavoriteBrands(Business $business)
|
||||
{
|
||||
// Get brands the buyer follows
|
||||
$followedBrandIds = BuyerBrandFollow::where('business_id', $business->id)
|
||||
->pluck('brand_id');
|
||||
|
||||
if ($followedBrandIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get products from those brands that user has ordered
|
||||
return Brand::whereIn('id', $followedBrandIds)
|
||||
->with(['products' => function ($query) use ($business) {
|
||||
$query->whereHas('orderItems.order', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['orderItems' => function ($q) use ($business) {
|
||||
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||
->latest()
|
||||
->limit(1);
|
||||
}])
|
||||
->where('is_active', true);
|
||||
}])
|
||||
->get()
|
||||
->filter(fn ($brand) => $brand->products->isNotEmpty());
|
||||
}
|
||||
|
||||
private function getPurchaseHistory(Business $business)
|
||||
{
|
||||
// Get all products ever ordered, grouped by brand
|
||||
$orderedProductIds = OrderItem::whereHas('order', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->distinct()->pluck('product_id');
|
||||
|
||||
if ($orderedProductIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Brand::whereHas('products', fn ($q) => $q->whereIn('id', $orderedProductIds))
|
||||
->with(['products' => function ($query) use ($orderedProductIds, $business) {
|
||||
$query->whereIn('id', $orderedProductIds)
|
||||
->with(['orderItems' => function ($q) use ($business) {
|
||||
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||
->latest()
|
||||
->limit(1);
|
||||
}]);
|
||||
}])
|
||||
->get();
|
||||
}
|
||||
|
||||
private function getStoreInventory(Business $business, $brands)
|
||||
{
|
||||
if ($brands->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$productIds = $brands->flatMap(fn ($b) => $b->products->pluck('id'));
|
||||
|
||||
try {
|
||||
$cannaiq = app(MarketingIntelligenceService::class);
|
||||
|
||||
return $cannaiq->getStoreMetrics($business->cannaiq_store_id, $productIds->toArray());
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail if CannaIQ unavailable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\ProductComparisonService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CompareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ProductComparisonService $comparison
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show the comparison page.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$products = $this->comparison->getProducts();
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.compare.index', compact('products', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current comparison state (AJAX).
|
||||
*/
|
||||
public function state(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ids' => $this->comparison->getProductIds(),
|
||||
'count' => $this->comparison->count(),
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
'max' => $this->comparison->maxItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product in the comparison list (AJAX).
|
||||
*/
|
||||
public function toggle(Product $product): JsonResponse
|
||||
{
|
||||
if (! $product->is_active) {
|
||||
return response()->json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
$result = $this->comparison->toggle($product->id);
|
||||
|
||||
return response()->json([
|
||||
'added' => $result['added'],
|
||||
'count' => $result['count'],
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
'message' => $result['added']
|
||||
? 'Added to comparison'
|
||||
: 'Removed from comparison',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from comparison list (AJAX).
|
||||
*/
|
||||
public function remove(Product $product): JsonResponse
|
||||
{
|
||||
$this->comparison->remove($product->id);
|
||||
|
||||
return response()->json([
|
||||
'count' => $this->comparison->count(),
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the comparison list.
|
||||
*/
|
||||
public function clear(): JsonResponse
|
||||
{
|
||||
$this->comparison->clear();
|
||||
|
||||
return response()->json([
|
||||
'count' => 0,
|
||||
'is_full' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Buyer\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Buyer\BuyerMessageSettings;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,9 +11,8 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class InboxController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
$filter = $request->get('filter', 'all');
|
||||
@@ -20,7 +20,7 @@ class InboxController extends Controller
|
||||
|
||||
$query = CrmThread::forBuyerBusiness($business->id)
|
||||
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount(['messages', 'unreadMessages as unread_count' => fn ($q) => $q->unreadForBuyer()]);
|
||||
->withCount('messages');
|
||||
|
||||
// Apply filters
|
||||
$query = match ($filter) {
|
||||
@@ -54,6 +54,7 @@ class InboxController extends Controller
|
||||
];
|
||||
|
||||
return view('buyer.crm.inbox.index', compact(
|
||||
'business',
|
||||
'threads',
|
||||
'filter',
|
||||
'search',
|
||||
@@ -62,9 +63,8 @@ class InboxController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function show(CrmThread $thread)
|
||||
public function show(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
// Verify thread belongs to this buyer
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -84,9 +84,8 @@ class InboxController extends Controller
|
||||
return view('buyer.crm.inbox.show', compact('thread'));
|
||||
}
|
||||
|
||||
public function compose(Request $request)
|
||||
public function compose(Request $request, Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
// Get brands the buyer has ordered from or can message
|
||||
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
|
||||
@@ -107,7 +106,7 @@ class InboxController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
@@ -117,7 +116,6 @@ class InboxController extends Controller
|
||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||
]);
|
||||
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
// Create thread
|
||||
@@ -143,9 +141,8 @@ class InboxController extends Controller
|
||||
->with('success', 'Message sent successfully.');
|
||||
}
|
||||
|
||||
public function star(CrmThread $thread)
|
||||
public function star(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -157,9 +154,8 @@ class InboxController extends Controller
|
||||
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
|
||||
}
|
||||
|
||||
public function archive(CrmThread $thread)
|
||||
public function archive(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -172,9 +168,8 @@ class InboxController extends Controller
|
||||
->with('success', 'Conversation archived.');
|
||||
}
|
||||
|
||||
public function unarchive(CrmThread $thread)
|
||||
public function unarchive(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -186,9 +181,8 @@ class InboxController extends Controller
|
||||
return back()->with('success', 'Conversation restored.');
|
||||
}
|
||||
|
||||
public function markAllRead()
|
||||
public function markAllRead(Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
CrmThread::forBuyerBusiness($business->id)
|
||||
->hasUnreadForBuyer()
|
||||
|
||||
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get quick view data for a product (AJAX endpoint).
|
||||
*/
|
||||
public function quickView(Product $product): JsonResponse
|
||||
{
|
||||
// Only return active products
|
||||
if (! $product->is_active) {
|
||||
return response()->json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
// Get the product's brand
|
||||
$product->load('brand:id,name,slug');
|
||||
|
||||
return response()->json([
|
||||
'id' => $product->id,
|
||||
'hashid' => $product->hashid,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'description' => $product->short_description ?? $product->description,
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price_unit' => $product->price_unit,
|
||||
'thc_percentage' => $product->thc_percentage,
|
||||
'cbd_percentage' => $product->cbd_percentage,
|
||||
'in_stock' => $product->isInStock(),
|
||||
'available_quantity' => $product->quantity_on_hand,
|
||||
'image_url' => $product->getImageUrl('medium'),
|
||||
'brand_name' => $product->brand?->name,
|
||||
'brand_slug' => $product->brand?->slug,
|
||||
'brand_url' => $product->brand ? route('buyer.brands.show', $product->brand->slug) : null,
|
||||
'url' => $product->brand ? route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Buyer Search Controller
|
||||
*
|
||||
* Provides search autocomplete endpoints for the marketplace header.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Search autocomplete for products and brands.
|
||||
*
|
||||
* GET /b/search/autocomplete?q=...
|
||||
*
|
||||
* Returns products and brands matching the query for dropdown suggestions.
|
||||
*/
|
||||
public function autocomplete(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim($request->input('q', ''));
|
||||
|
||||
if (strlen($query) < 2) {
|
||||
return response()->json(['products' => [], 'brands' => []]);
|
||||
}
|
||||
|
||||
// Search products (limit 8)
|
||||
$products = Product::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||
->with('brand:id,name,slug')
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('sku', 'ILIKE', "%{$query}%")
|
||||
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
|
||||
})
|
||||
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||
->orderBy('name')
|
||||
->limit(8)
|
||||
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price', 'image_path']);
|
||||
|
||||
// Search brands (limit 4)
|
||||
$brands = Brand::query()
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('description', 'ILIKE', "%{$query}%");
|
||||
})
|
||||
->withCount('products')
|
||||
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||
->orderBy('name')
|
||||
->limit(4)
|
||||
->get(['id', 'name', 'slug', 'logo_path']);
|
||||
|
||||
return response()->json([
|
||||
'products' => $products->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'hashid' => $p->hashid,
|
||||
'name' => $p->name,
|
||||
'sku' => $p->sku,
|
||||
'price' => $p->wholesale_price ?? 0,
|
||||
'image_url' => $p->getImageUrl('thumb'),
|
||||
'brand_name' => $p->brand?->name,
|
||||
'brand_slug' => $p->brand?->slug,
|
||||
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||
]),
|
||||
'brands' => $brands->map(fn ($b) => [
|
||||
'id' => $b->id,
|
||||
'name' => $b->name,
|
||||
'slug' => $b->slug,
|
||||
'logo_url' => $b->getLogoUrl('thumb'),
|
||||
'products_count' => $b->products_count,
|
||||
'url' => route('buyer.brands.show', $b->slug),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions based on popular searches.
|
||||
*
|
||||
* GET /b/search/suggestions
|
||||
*
|
||||
* Returns popular search terms and trending products.
|
||||
*/
|
||||
public function suggestions(): JsonResponse
|
||||
{
|
||||
// Popular search terms (could be tracked and stored, for now use static list)
|
||||
$popularTerms = [
|
||||
'gummies',
|
||||
'vape',
|
||||
'flower',
|
||||
'indica',
|
||||
'sativa',
|
||||
'edibles',
|
||||
'pre-roll',
|
||||
'concentrate',
|
||||
];
|
||||
|
||||
// Trending products (recently added or best sellers)
|
||||
$trending = Product::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||
->with('brand:id,name,slug')
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(4)
|
||||
->get(['id', 'brand_id', 'name', 'image_path']);
|
||||
|
||||
return response()->json([
|
||||
'terms' => $popularTerms,
|
||||
'trending' => $trending->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'hashid' => $p->hashid,
|
||||
'name' => $p->name,
|
||||
'image_url' => $p->getImageUrl('thumb'),
|
||||
'brand_name' => $p->brand?->name,
|
||||
'brand_slug' => $p->brand?->slug,
|
||||
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,39 @@ use Intervention\Image\ImageManager;
|
||||
*/
|
||||
class ImageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Cache duration for images (1 year in seconds)
|
||||
*/
|
||||
private const CACHE_TTL = 31536000;
|
||||
|
||||
/**
|
||||
* Return a cached response for an image
|
||||
*/
|
||||
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
|
||||
{
|
||||
$response = response($contents)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
|
||||
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
|
||||
|
||||
if ($etag) {
|
||||
$response->header('ETag', '"'.$etag.'"');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a cached file response
|
||||
*/
|
||||
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
return response()->file($path, [
|
||||
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
|
||||
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a brand logo at a specific size
|
||||
* URL: /images/brand-logo/{brand}/{width?}
|
||||
@@ -67,8 +100,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($brand->logo_path);
|
||||
$mimeType = Storage::mimeType($brand->logo_path);
|
||||
$etag = md5($brand->logo_path.$brand->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
@@ -104,7 +138,7 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,8 +155,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($brand->banner_path);
|
||||
$mimeType = Storage::mimeType($brand->banner_path);
|
||||
$etag = md5($brand->banner_path.$brand->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
@@ -155,7 +190,7 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,8 +207,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($product->image_path);
|
||||
$mimeType = Storage::mimeType($product->image_path);
|
||||
$etag = md5($product->image_path.$product->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists in local storage
|
||||
@@ -202,6 +238,54 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a product image from the product_images table by ID
|
||||
* URL: /images/product-image/{productImage}/{width?}
|
||||
*/
|
||||
public function productImageById(\App\Models\ProductImage $productImage, ?int $width = null)
|
||||
{
|
||||
if (! $productImage->path || ! Storage::exists($productImage->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If no width specified, return original from storage
|
||||
if (! $width) {
|
||||
$contents = Storage::get($productImage->path);
|
||||
$mimeType = Storage::mimeType($productImage->path);
|
||||
$etag = md5($productImage->path.$productImage->updated_at);
|
||||
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists in local storage
|
||||
$ext = pathinfo($productImage->path, PATHINFO_EXTENSION);
|
||||
$thumbnailName = 'pi-'.$productImage->id.'-'.$width.'w.'.$ext;
|
||||
$thumbnailPath = 'products/cache/'.$thumbnailName;
|
||||
|
||||
if (! Storage::disk('local')->exists($thumbnailPath)) {
|
||||
// Fetch original from default storage disk (MinIO)
|
||||
$originalContents = Storage::get($productImage->path);
|
||||
|
||||
// Generate thumbnail on-the-fly
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read($originalContents);
|
||||
$image->scale(width: $width);
|
||||
|
||||
// Cache the thumbnail locally for performance
|
||||
if (! Storage::disk('local')->exists('products/cache')) {
|
||||
Storage::disk('local')->makeDirectory('products/cache');
|
||||
}
|
||||
|
||||
// Save as PNG or JPEG based on original format
|
||||
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||
Storage::disk('local')->put($thumbnailPath, $encoded);
|
||||
}
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,29 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductCategory;
|
||||
use App\Models\Strain;
|
||||
use App\Services\RecentlyViewedService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketplaceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RecentlyViewedService $recentlyViewed
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display marketplace browse page
|
||||
* Display marketplace browse page (Amazon/Shopify style)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = auth()->user()->businesses->first();
|
||||
$hasFilters = $request->hasAny(['search', 'brand_id', 'strain_type', 'price_min', 'price_max', 'in_stock', 'category_id']);
|
||||
|
||||
// Start with active products only
|
||||
$query = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type', 'category:id,name,slug'])
|
||||
->active();
|
||||
|
||||
// Search filter (name, SKU, description)
|
||||
@@ -28,15 +38,24 @@ class MarketplaceController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($brandId = $request->input('brand_id')) {
|
||||
$query->where('brand_id', $brandId);
|
||||
// Brand filter (supports multiple)
|
||||
if ($brandIds = $request->input('brand_id')) {
|
||||
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
|
||||
$query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
// Strain type filter
|
||||
// Category filter (uses category_id foreign key)
|
||||
if ($categoryId = $request->input('category_id')) {
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
// Strain type filter - use join instead of whereHas for performance
|
||||
if ($strainType = $request->input('strain_type')) {
|
||||
$query->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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,23 +83,121 @@ class MarketplaceController extends Controller
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
// View mode (grid/list)
|
||||
$viewMode = $request->input('view', 'grid');
|
||||
|
||||
// Paginate results
|
||||
$products = $query->paginate(12)->withQueryString();
|
||||
$perPage = $viewMode === 'list' ? 10 : 12;
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get all active brands for filters
|
||||
$brands = Brand::active()->orderBy('name')->get();
|
||||
// Cache brands and categories for 5 minutes (used frequently, rarely change)
|
||||
$brands = cache()->remember('marketplace:brands', 300, function () {
|
||||
return Brand::query()
|
||||
->active()
|
||||
->whereExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('products')
|
||||
->whereColumn('products.brand_id', 'brands.id')
|
||||
->where('products.is_active', true);
|
||||
})
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
});
|
||||
|
||||
// Get featured products for carousel (exclude from main results if in first page)
|
||||
$featuredProducts = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(3)
|
||||
->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();
|
||||
});
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
// Only load extra sections if not filtering (homepage view)
|
||||
$featuredProducts = collect();
|
||||
$topBrands = collect();
|
||||
$newArrivals = collect();
|
||||
$trending = collect();
|
||||
$recentlyViewed = collect();
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
|
||||
if (! $hasFilters) {
|
||||
// Featured products for hero carousel
|
||||
$featuredProducts = Product::query()
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Top brands - reuse cached brands
|
||||
$topBrands = $brands->sortByDesc('products_count')->take(6);
|
||||
|
||||
// New arrivals (products created in last 14 days)
|
||||
$newArrivals = Product::query()
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->active()
|
||||
->inStock()
|
||||
->where('created_at', '>=', now()->subDays(14))
|
||||
->orderByDesc('created_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
// Trending products - cache for 10 minutes
|
||||
$trending = cache()->remember('marketplace:trending', 600, function () {
|
||||
$trendingIds = DB::table('order_items')
|
||||
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('total_sold')
|
||||
->limit(8)
|
||||
->pluck('product_id');
|
||||
|
||||
if ($trendingIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Product::with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->whereIn('id', $trendingIds)
|
||||
->active()
|
||||
->get()
|
||||
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()));
|
||||
});
|
||||
|
||||
// Recently viewed products
|
||||
$recentlyViewed = $this->recentlyViewed->getProducts(6);
|
||||
}
|
||||
|
||||
// Active filters for pills display
|
||||
$activeFilters = collect([
|
||||
'search' => $request->input('search'),
|
||||
'brand_id' => $request->input('brand_id'),
|
||||
'category_id' => $request->input('category_id'),
|
||||
'strain_type' => $request->input('strain_type'),
|
||||
'in_stock' => $request->input('in_stock'),
|
||||
])->filter();
|
||||
|
||||
return view('buyer.marketplace.index', compact(
|
||||
'products',
|
||||
'brands',
|
||||
'categories',
|
||||
'featuredProducts',
|
||||
'topBrands',
|
||||
'newArrivals',
|
||||
'trending',
|
||||
'recentlyViewed',
|
||||
'business',
|
||||
'viewMode',
|
||||
'activeFilters',
|
||||
'hasFilters'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,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);
|
||||
}
|
||||
@@ -152,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()
|
||||
@@ -162,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'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,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'])
|
||||
->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'])
|
||||
->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);
|
||||
|
||||
|
||||
@@ -97,6 +97,135 @@ class OrderController extends Controller
|
||||
return view('seller.orders.index', compact('orders', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new order (seller-initiated).
|
||||
*/
|
||||
public function create(\App\Models\Business $business): View
|
||||
{
|
||||
// Get all buyer businesses for the customer dropdown
|
||||
$buyers = \App\Models\Business::where('is_active', true)
|
||||
->whereIn('business_type', ['buyer', 'both'])
|
||||
->with(['locations' => function ($query) {
|
||||
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get recently ordered products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
->whereHas('orderItems', function ($query) {
|
||||
$query->where('created_at', '>=', now()->subDays(30));
|
||||
})
|
||||
->with(['brand', 'images'])
|
||||
->withCount(['orderItems' => function ($query) {
|
||||
$query->where('created_at', '>=', now()->subDays(30));
|
||||
}])
|
||||
->orderByDesc('order_items_count')
|
||||
->take(10)
|
||||
->get()
|
||||
->map(function ($product) use ($business) {
|
||||
// Calculate inventory from InventoryItem model
|
||||
$totalOnHand = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_on_hand');
|
||||
$totalAllocated = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_allocated');
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'brand_name' => $product->brand?->name,
|
||||
'wholesale_price' => $product->wholesale_price,
|
||||
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
|
||||
'type' => $product->type,
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.orders.create', compact('business', 'buyers', 'recentProducts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created order (seller-initiated).
|
||||
*/
|
||||
public function store(\App\Models\Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'buyer_business_id' => 'required|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:locations,id',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|integer|min:1',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.discount_amount' => 'nullable|numeric|min:0',
|
||||
'items.*.discount_type' => 'nullable|in:fixed,percent',
|
||||
'items.*.notes' => 'nullable|string|max:500',
|
||||
'items.*.batch_id' => 'nullable|exists:batches,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Create the order
|
||||
$order = Order::create([
|
||||
'business_id' => $validated['buyer_business_id'],
|
||||
'location_id' => $validated['location_id'] ?? null,
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'user_id' => auth()->id(),
|
||||
'status' => 'new',
|
||||
'created_by' => 'seller',
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Add line items
|
||||
$subtotal = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
$product = \App\Models\Product::findOrFail($item['product_id']);
|
||||
|
||||
$lineSubtotal = $item['quantity'] * $item['unit_price'];
|
||||
$discountAmount = 0;
|
||||
|
||||
if (! empty($item['discount_amount']) && $item['discount_amount'] > 0) {
|
||||
if (($item['discount_type'] ?? 'percent') === 'percent') {
|
||||
$discountAmount = $lineSubtotal * ($item['discount_amount'] / 100);
|
||||
} else {
|
||||
$discountAmount = $item['discount_amount'];
|
||||
}
|
||||
}
|
||||
|
||||
$lineTotal = $lineSubtotal - $discountAmount;
|
||||
$subtotal += $lineTotal;
|
||||
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'batch_id' => $item['batch_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'price' => $item['unit_price'],
|
||||
'discount_amount' => $discountAmount,
|
||||
'total' => $lineTotal,
|
||||
'notes' => $item['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update order totals
|
||||
$order->update([
|
||||
'subtotal' => $subtotal,
|
||||
'total' => $subtotal, // Tax can be added later
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order created successfully!');
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('error', 'Failed to create order: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order detail with workorder/picking ticket functionality.
|
||||
*/
|
||||
@@ -213,6 +342,41 @@ class OrderController extends Controller
|
||||
return back()->with('success', "Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item comment for an order line item.
|
||||
*/
|
||||
public function updateItemComment(\App\Models\Business $business, Order $order, \App\Models\OrderItem $orderItem, Request $request): RedirectResponse
|
||||
{
|
||||
// Verify the item belongs to this order
|
||||
if ($orderItem->order_id !== $order->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'item_comment' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$orderItem->update([
|
||||
'item_comment' => $validated['item_comment'],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Item comment updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle ping pong mode for an order.
|
||||
*/
|
||||
public function togglePingPong(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
$order->update([
|
||||
'is_ping_pong' => ! $order->is_ping_pong,
|
||||
]);
|
||||
|
||||
$status = $order->is_ping_pong ? 'enabled' : 'disabled';
|
||||
|
||||
return back()->with('success', "Ping Pong flow {$status} for this order.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve order for delivery (after buyer selects delivery method).
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,7 @@ class BrandController extends Controller
|
||||
'is_active' => $brand->is_active,
|
||||
'is_public' => $brand->is_public,
|
||||
'is_featured' => $brand->is_featured,
|
||||
'is_cannaiq_connected' => $brand->isCannaiqConnected(),
|
||||
'products_count' => $brand->products_count ?? 0,
|
||||
'updated_at' => $brand->updated_at?->diffForHumans(),
|
||||
'website_url' => $brand->website_url,
|
||||
@@ -2099,6 +2100,146 @@ class BrandController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect brand to CannaiQ API.
|
||||
*
|
||||
* Normalizes the brand name and stores as cannaiq_brand_key.
|
||||
*/
|
||||
public function cannaiqConnect(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$brand->connectToCannaiq($validated['brand_name']);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Brand connected to CannaiQ',
|
||||
'cannaiq_brand_key' => $brand->cannaiq_brand_key,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||
->with('success', 'Brand connected to CannaiQ successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect brand from CannaiQ API.
|
||||
*/
|
||||
public function cannaiqDisconnect(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
$brand->disconnectFromCannaiq();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Brand disconnected from CannaiQ',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||
->with('success', 'Brand disconnected from CannaiQ.');
|
||||
}
|
||||
|
||||
/**
|
||||
* CannaiQ product mapping page.
|
||||
*
|
||||
* Shows Hub products for this brand and allows mapping to CannaiQ products.
|
||||
*/
|
||||
public function cannaiqMapping(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
if (! $brand->isCannaiqConnected()) {
|
||||
return redirect()
|
||||
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||
->with('error', 'Please connect this brand to CannaiQ first.');
|
||||
}
|
||||
|
||||
$products = $brand->products()
|
||||
->with('cannaiqMappings')
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.cannaiq-mapping', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Hub product to a CannaiQ product.
|
||||
*/
|
||||
public function cannaiqMapProduct(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannaiq_product_id' => 'required|integer',
|
||||
'cannaiq_product_name' => 'required|string|max:255',
|
||||
'cannaiq_store_id' => 'nullable|string|max:255',
|
||||
'cannaiq_store_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
// Verify product belongs to this brand
|
||||
$product = $brand->products()->findOrFail($validated['product_id']);
|
||||
|
||||
// Create mapping (ignore if already exists)
|
||||
$mapping = $product->cannaiqMappings()->firstOrCreate(
|
||||
['cannaiq_product_id' => $validated['cannaiq_product_id']],
|
||||
[
|
||||
'cannaiq_product_name' => $validated['cannaiq_product_name'],
|
||||
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
|
||||
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'mapping' => $mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Product mapped successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product mapping.
|
||||
*/
|
||||
public function cannaiqUnmapProduct(Request $request, Business $business, Brand $brand, \App\Models\ProductCannaiqMapping $mapping)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
// Verify mapping belongs to a product of this brand
|
||||
if ($mapping->product->brand_id !== $brand->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$mapping->delete();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Mapping removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate store/distribution metrics for the brand.
|
||||
*
|
||||
|
||||
@@ -246,7 +246,7 @@ class QuoteController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
|
||||
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product.brand', 'invoice', 'files']);
|
||||
|
||||
return view('seller.crm.quotes.show', compact('quote', 'business'));
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ class ProductController extends Controller
|
||||
// Get brand IDs to filter by (respects brand context switcher)
|
||||
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Get all brands for the business for the filter dropdown
|
||||
// Get all brands for the business for the filter dropdown and new product button
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
->get(['id', 'name', 'hashid', 'logo_path', 'slug', 'updated_at']);
|
||||
|
||||
// Calculate missing BOM count for health alert
|
||||
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
||||
|
||||
@@ -70,8 +70,8 @@ class ProductImageController extends Controller
|
||||
'id' => $image->id,
|
||||
'path' => $image->path,
|
||||
'is_primary' => $image->is_primary,
|
||||
'url' => route('image.product', ['product' => $product->hashid, 'width' => 400]),
|
||||
'thumb_url' => route('image.product', ['product' => $product->hashid, 'width' => 80]),
|
||||
'url' => route('image.product-image', ['productImage' => $image->id, 'width' => 400]),
|
||||
'thumb_url' => route('image.product-image', ['productImage' => $image->id, 'width' => 80]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
263
app/Http/Controllers/Seller/Settings/DbaController.php
Normal file
263
app/Http/Controllers/Seller/Settings/DbaController.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DbaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of all DBAs for the business.
|
||||
*/
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$dbas = $business->dbas()
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('trade_name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.dbas.index', compact('business', 'dbas'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new DBA.
|
||||
*/
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.settings.dbas.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created DBA in storage.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Identity
|
||||
'trade_name' => 'required|string|max:255',
|
||||
|
||||
// Address
|
||||
'address' => 'nullable|string|max:255',
|
||||
'address_line_2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:255',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip' => 'nullable|string|max:10',
|
||||
|
||||
// License
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string|max:255',
|
||||
'license_expiration' => 'nullable|date',
|
||||
|
||||
// Bank Info
|
||||
'bank_name' => 'nullable|string|max:255',
|
||||
'bank_account_name' => 'nullable|string|max:255',
|
||||
'bank_routing_number' => 'nullable|string|max:50',
|
||||
'bank_account_number' => 'nullable|string|max:50',
|
||||
'bank_account_type' => 'nullable|string|in:checking,savings',
|
||||
|
||||
// Tax
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'tax_id_type' => 'nullable|string|in:ein,ssn',
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => 'nullable|string|max:255',
|
||||
'primary_contact_email' => 'nullable|email|max:255',
|
||||
'primary_contact_phone' => 'nullable|string|max:50',
|
||||
'ap_contact_name' => 'nullable|string|max:255',
|
||||
'ap_contact_email' => 'nullable|email|max:255',
|
||||
'ap_contact_phone' => 'nullable|string|max:50',
|
||||
|
||||
// Invoice Settings
|
||||
'payment_terms' => 'nullable|string|max:50',
|
||||
'payment_instructions' => 'nullable|string|max:2000',
|
||||
'invoice_footer' => 'nullable|string|max:2000',
|
||||
'invoice_prefix' => 'nullable|string|max:10',
|
||||
|
||||
// Branding
|
||||
'logo_path' => 'nullable|string|max:255',
|
||||
'brand_colors' => 'nullable|array',
|
||||
|
||||
// Status
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_default'] = $request->boolean('is_default');
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
$dba = BusinessDba::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$dba->trade_name}\" created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified DBA.
|
||||
*/
|
||||
public function edit(Business $business, BusinessDba $dba): View
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
return view('seller.settings.dbas.edit', compact('business', 'dba'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified DBA in storage.
|
||||
*/
|
||||
public function update(Request $request, Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
// Identity
|
||||
'trade_name' => 'required|string|max:255',
|
||||
|
||||
// Address
|
||||
'address' => 'nullable|string|max:255',
|
||||
'address_line_2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:255',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip' => 'nullable|string|max:10',
|
||||
|
||||
// License
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string|max:255',
|
||||
'license_expiration' => 'nullable|date',
|
||||
|
||||
// Bank Info
|
||||
'bank_name' => 'nullable|string|max:255',
|
||||
'bank_account_name' => 'nullable|string|max:255',
|
||||
'bank_routing_number' => 'nullable|string|max:50',
|
||||
'bank_account_number' => 'nullable|string|max:50',
|
||||
'bank_account_type' => 'nullable|string|in:checking,savings',
|
||||
|
||||
// Tax
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'tax_id_type' => 'nullable|string|in:ein,ssn',
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => 'nullable|string|max:255',
|
||||
'primary_contact_email' => 'nullable|email|max:255',
|
||||
'primary_contact_phone' => 'nullable|string|max:50',
|
||||
'ap_contact_name' => 'nullable|string|max:255',
|
||||
'ap_contact_email' => 'nullable|email|max:255',
|
||||
'ap_contact_phone' => 'nullable|string|max:50',
|
||||
|
||||
// Invoice Settings
|
||||
'payment_terms' => 'nullable|string|max:50',
|
||||
'payment_instructions' => 'nullable|string|max:2000',
|
||||
'invoice_footer' => 'nullable|string|max:2000',
|
||||
'invoice_prefix' => 'nullable|string|max:10',
|
||||
|
||||
// Branding
|
||||
'logo_path' => 'nullable|string|max:255',
|
||||
'brand_colors' => 'nullable|array',
|
||||
|
||||
// Status
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['is_default'] = $request->boolean('is_default');
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
// Don't overwrite encrypted fields if left blank (preserve existing values)
|
||||
$encryptedFields = ['bank_routing_number', 'bank_account_number', 'tax_id'];
|
||||
foreach ($encryptedFields as $field) {
|
||||
if (empty($validated[$field])) {
|
||||
unset($validated[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$dba->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$dba->trade_name}\" updated successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified DBA from storage.
|
||||
*/
|
||||
public function destroy(Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
// Check if this is the only active DBA
|
||||
$activeCount = $business->dbas()->where('is_active', true)->count();
|
||||
if ($activeCount <= 1 && $dba->is_active) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('error', 'You cannot delete the only active DBA. Create another DBA first or deactivate this one.');
|
||||
}
|
||||
|
||||
$tradeName = $dba->trade_name;
|
||||
$dba->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$tradeName}\" deleted successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the specified DBA as the default for the business.
|
||||
*/
|
||||
public function setDefault(Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
$dba->markAsDefault();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "\"{$dba->trade_name}\" is now your default DBA for invoices.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active status of a DBA.
|
||||
*/
|
||||
public function toggleActive(Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
// Prevent deactivating if it's the only active DBA
|
||||
if ($dba->is_active) {
|
||||
$activeCount = $business->dbas()->where('is_active', true)->count();
|
||||
if ($activeCount <= 1) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('error', 'You cannot deactivate the only active DBA.');
|
||||
}
|
||||
}
|
||||
|
||||
$dba->update(['is_active' => ! $dba->is_active]);
|
||||
|
||||
$status = $dba->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$dba->trade_name}\" has been {$status}.");
|
||||
}
|
||||
}
|
||||
66
app/Jobs/RollupBannerAdStats.php
Normal file
66
app/Jobs/RollupBannerAdStats.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BannerAdDailyStat;
|
||||
use App\Models\BannerAdEvent;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RollupBannerAdStats implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
protected ?string $date = null
|
||||
) {
|
||||
$this->date = $date ?? now()->subDay()->toDateString();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$stats = BannerAdEvent::query()
|
||||
->whereDate('created_at', $this->date)
|
||||
->select([
|
||||
'banner_ad_id',
|
||||
DB::raw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) as impressions"),
|
||||
DB::raw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) as clicks"),
|
||||
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN session_id END) as unique_impressions"),
|
||||
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'click' THEN session_id END) as unique_clicks"),
|
||||
])
|
||||
->groupBy('banner_ad_id')
|
||||
->get();
|
||||
|
||||
$created = 0;
|
||||
foreach ($stats as $stat) {
|
||||
BannerAdDailyStat::updateOrCreate(
|
||||
[
|
||||
'banner_ad_id' => $stat->banner_ad_id,
|
||||
'date' => $this->date,
|
||||
],
|
||||
[
|
||||
'impressions' => $stat->impressions,
|
||||
'clicks' => $stat->clicks,
|
||||
'unique_impressions' => $stat->unique_impressions,
|
||||
'unique_clicks' => $stat->unique_clicks,
|
||||
]
|
||||
);
|
||||
$created++;
|
||||
}
|
||||
|
||||
if ($created > 0) {
|
||||
Log::info("Banner ad daily stats rolled up: {$created} records for {$this->date}");
|
||||
}
|
||||
|
||||
// Optionally clean up old events (older than 30 days)
|
||||
$deleted = BannerAdEvent::where('created_at', '<', now()->subDays(30))->delete();
|
||||
if ($deleted > 0) {
|
||||
Log::info("Cleaned up {$deleted} old banner ad events");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Jobs/UpdateBannerAdStatuses.php
Normal file
25
app/Jobs/UpdateBannerAdStatuses.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\BannerAdService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdateBannerAdStatuses implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(BannerAdService $service): void
|
||||
{
|
||||
$updated = $service->updateScheduledStatuses();
|
||||
|
||||
if ($updated > 0) {
|
||||
Log::info("Banner ad statuses updated: {$updated} ads changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
201
app/Models/BannerAd.php
Normal file
201
app/Models/BannerAd.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BannerAdStatus;
|
||||
use App\Enums\BannerAdZone;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BannerAd extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'brand_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'headline',
|
||||
'description',
|
||||
'cta_text',
|
||||
'cta_url',
|
||||
'image_path',
|
||||
'image_alt',
|
||||
'zone',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'target_business_types',
|
||||
'is_platform_wide',
|
||||
'status',
|
||||
'priority',
|
||||
'weight',
|
||||
'impressions',
|
||||
'clicks',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'target_business_types' => 'array',
|
||||
'is_platform_wide' => 'boolean',
|
||||
'status' => BannerAdStatus::class,
|
||||
'zone' => BannerAdZone::class,
|
||||
'impressions' => 'integer',
|
||||
'clicks' => 'integer',
|
||||
'priority' => 'integer',
|
||||
'weight' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(BannerAdEvent::class);
|
||||
}
|
||||
|
||||
public function dailyStats(): HasMany
|
||||
{
|
||||
return $this->hasMany(BannerAdDailyStat::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', BannerAdStatus::ACTIVE)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('starts_at')->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ends_at')->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForZone($query, BannerAdZone|string $zone)
|
||||
{
|
||||
$zoneValue = $zone instanceof BannerAdZone ? $zone->value : $zone;
|
||||
|
||||
return $query->where('zone', $zoneValue);
|
||||
}
|
||||
|
||||
public function scopePlatformWide($query)
|
||||
{
|
||||
return $query->where('is_platform_wide', true);
|
||||
}
|
||||
|
||||
public function scopeForBusinessType($query, ?string $businessType)
|
||||
{
|
||||
if (! $businessType) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function ($q) use ($businessType) {
|
||||
$q->whereNull('target_business_types')
|
||||
->orWhereJsonContains('target_business_types', $businessType);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeScheduled($query)
|
||||
{
|
||||
return $query->where('status', BannerAdStatus::SCHEDULED)
|
||||
->whereNotNull('starts_at')
|
||||
->where('starts_at', '>', now());
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('status', BannerAdStatus::EXPIRED);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->image_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($this->image_path);
|
||||
}
|
||||
|
||||
public function getClickThroughRateAttribute(): float
|
||||
{
|
||||
if ($this->impressions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->clicks / $this->impressions) * 100, 2);
|
||||
}
|
||||
|
||||
public function getIsCurrentlyActiveAttribute(): bool
|
||||
{
|
||||
if ($this->status !== BannerAdStatus::ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
if ($this->starts_at && $this->starts_at > $now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at < $now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getDimensionsAttribute(): array
|
||||
{
|
||||
return $this->zone?->dimensions() ?? ['width' => 728, 'height' => 90, 'display' => '728x90'];
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
public function incrementImpressions(): void
|
||||
{
|
||||
$this->increment('impressions');
|
||||
}
|
||||
|
||||
public function incrementClicks(): void
|
||||
{
|
||||
$this->increment('clicks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URL for serving via controller
|
||||
*/
|
||||
public function getImageUrl(?int $width = null): ?string
|
||||
{
|
||||
if (! $this->image_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('image.banner-ad', [
|
||||
'bannerAd' => $this->id,
|
||||
'width' => $width,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get click tracking URL
|
||||
*/
|
||||
public function getClickUrl(): string
|
||||
{
|
||||
return route('banner-ad.click', $this->id);
|
||||
}
|
||||
}
|
||||
65
app/Models/BannerAdDailyStat.php
Normal file
65
app/Models/BannerAdDailyStat.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BannerAdDailyStat extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'banner_ad_id',
|
||||
'date',
|
||||
'impressions',
|
||||
'clicks',
|
||||
'unique_impressions',
|
||||
'unique_clicks',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'impressions' => 'integer',
|
||||
'clicks' => 'integer',
|
||||
'unique_impressions' => 'integer',
|
||||
'unique_clicks' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function bannerAd(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BannerAd::class);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getClickThroughRateAttribute(): float
|
||||
{
|
||||
if ($this->impressions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->clicks / $this->impressions) * 100, 2);
|
||||
}
|
||||
|
||||
public function getUniqueClickThroughRateAttribute(): float
|
||||
{
|
||||
if ($this->unique_impressions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->unique_clicks / $this->unique_impressions) * 100, 2);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
public function scopeForAd($query, $bannerAdId)
|
||||
{
|
||||
return $query->where('banner_ad_id', $bannerAdId);
|
||||
}
|
||||
}
|
||||
74
app/Models/BannerAdEvent.php
Normal file
74
app/Models/BannerAdEvent.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BannerAdEvent extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'banner_ad_id',
|
||||
'business_id',
|
||||
'user_id',
|
||||
'event_type',
|
||||
'session_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'page_url',
|
||||
'referer',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $event) {
|
||||
$event->created_at = $event->created_at ?? now();
|
||||
});
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function bannerAd(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BannerAd::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeImpressions($query)
|
||||
{
|
||||
return $query->where('event_type', 'impression');
|
||||
}
|
||||
|
||||
public function scopeClicks($query)
|
||||
{
|
||||
return $query->where('event_type', 'click');
|
||||
}
|
||||
|
||||
public function scopeForDate($query, $date)
|
||||
{
|
||||
return $query->whereDate('created_at', $date);
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,9 @@ class Brand extends Model implements Auditable
|
||||
|
||||
// CRM Channel for inbound emails
|
||||
'inbound_email_channel_id',
|
||||
|
||||
// CannaIQ Integration
|
||||
'cannaiq_brand_key', // Normalized brand name for CannaIQ API (e.g., "alohatymemachine")
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -161,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)
|
||||
*/
|
||||
@@ -325,6 +336,47 @@ class Brand extends Model implements Auditable
|
||||
->get();
|
||||
}
|
||||
|
||||
// CannaIQ Integration
|
||||
|
||||
/**
|
||||
* Check if brand is connected to CannaIQ
|
||||
*/
|
||||
public function isCannaiqConnected(): bool
|
||||
{
|
||||
return ! empty($this->cannaiq_brand_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a brand name for CannaIQ API key
|
||||
* Removes spaces, special chars, converts to lowercase
|
||||
*
|
||||
* Example: "Aloha TymeMachine" → "alohatymemachine"
|
||||
*/
|
||||
public static function normalizeCannaiqKey(string $brandName): string
|
||||
{
|
||||
// Remove all non-alphanumeric characters and convert to lowercase
|
||||
return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $brandName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect brand to CannaIQ using brand name
|
||||
* Normalizes the name and stores as cannaiq_brand_key
|
||||
*/
|
||||
public function connectToCannaiq(string $brandName): void
|
||||
{
|
||||
$this->cannaiq_brand_key = self::normalizeCannaiqKey($brandName);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect brand from CannaIQ
|
||||
*/
|
||||
public function disconnectFromCannaiq(): void
|
||||
{
|
||||
$this->cannaiq_brand_key = null;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
*/
|
||||
|
||||
@@ -293,6 +293,7 @@ class Business extends Model implements AuditableContract
|
||||
'has_enterprise_suite',
|
||||
'use_suite_navigation',
|
||||
'cannaiq_enabled',
|
||||
'ping_pong_enabled',
|
||||
|
||||
// Sales Suite Usage Limits
|
||||
'sales_suite_brand_limit',
|
||||
@@ -368,6 +369,7 @@ class Business extends Model implements AuditableContract
|
||||
'is_enterprise_plan' => 'boolean', // Plan limit override - when true, usage limits are not enforced
|
||||
'use_suite_navigation' => 'boolean',
|
||||
'cannaiq_enabled' => 'boolean',
|
||||
'ping_pong_enabled' => 'boolean',
|
||||
// Sales Suite Usage Limits
|
||||
'sales_suite_brand_limit' => 'integer',
|
||||
'sales_suite_sku_limit_per_brand' => 'integer',
|
||||
@@ -531,6 +533,47 @@ class Business extends Model implements AuditableContract
|
||||
return $this->hasMany(Brand::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DBA (Doing Business As) Relationships
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get all DBAs for this business.
|
||||
*/
|
||||
public function dbas(): HasMany
|
||||
{
|
||||
return $this->hasMany(BusinessDba::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active DBAs for this business.
|
||||
*/
|
||||
public function activeDbas(): HasMany
|
||||
{
|
||||
return $this->hasMany(BusinessDba::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default DBA for this business.
|
||||
*/
|
||||
public function defaultDba(): HasOne
|
||||
{
|
||||
return $this->hasOne(BusinessDba::class)->where('is_default', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DBA for invoice generation.
|
||||
* Priority: explicit dba_id > default DBA > first active DBA > null
|
||||
*/
|
||||
public function getDbaForInvoice(?int $dbaId = null): ?BusinessDba
|
||||
{
|
||||
if ($dbaId) {
|
||||
return $this->dbas()->find($dbaId);
|
||||
}
|
||||
|
||||
return $this->defaultDba ?? $this->activeDbas()->first();
|
||||
}
|
||||
|
||||
public function brandAiProfiles(): HasMany
|
||||
{
|
||||
return $this->hasMany(BrandAiProfile::class);
|
||||
|
||||
250
app/Models/BusinessDba.php
Normal file
250
app/Models/BusinessDba.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class BusinessDba extends Model implements Auditable
|
||||
{
|
||||
use BelongsToBusinessDirectly;
|
||||
use HasFactory;
|
||||
use \OwenIt\Auditing\Auditable;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'business_dbas';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'trade_name',
|
||||
'slug',
|
||||
// Address
|
||||
'address',
|
||||
'address_line_2',
|
||||
'city',
|
||||
'state',
|
||||
'zip',
|
||||
// License
|
||||
'license_number',
|
||||
'license_type',
|
||||
'license_expiration',
|
||||
// Bank Info
|
||||
'bank_name',
|
||||
'bank_account_name',
|
||||
'bank_routing_number',
|
||||
'bank_account_number',
|
||||
'bank_account_type',
|
||||
// Tax
|
||||
'tax_id',
|
||||
'tax_id_type',
|
||||
// Contacts
|
||||
'primary_contact_name',
|
||||
'primary_contact_email',
|
||||
'primary_contact_phone',
|
||||
'ap_contact_name',
|
||||
'ap_contact_email',
|
||||
'ap_contact_phone',
|
||||
// Invoice Settings
|
||||
'payment_terms',
|
||||
'payment_instructions',
|
||||
'invoice_footer',
|
||||
'invoice_prefix',
|
||||
// Branding
|
||||
'logo_path',
|
||||
'brand_colors',
|
||||
// Status
|
||||
'is_default',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'brand_colors' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'license_expiration' => 'date',
|
||||
// Encrypted fields
|
||||
'bank_routing_number' => 'encrypted',
|
||||
'bank_account_number' => 'encrypted',
|
||||
'tax_id' => 'encrypted',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields to exclude from audit logging (sensitive data)
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'bank_routing_number',
|
||||
'bank_account_number',
|
||||
'tax_id',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Crm\CrmInvoice::class, 'dba_id');
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class, 'seller_dba_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scopes
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessors
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get the full formatted address.
|
||||
*/
|
||||
public function getFullAddressAttribute(): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$this->address,
|
||||
$this->address_line_2,
|
||||
]);
|
||||
|
||||
$cityStateZip = trim(
|
||||
($this->city ?? '').
|
||||
($this->city && $this->state ? ', ' : '').
|
||||
($this->state ?? '').' '.
|
||||
($this->zip ?? '')
|
||||
);
|
||||
|
||||
if ($cityStateZip) {
|
||||
$parts[] = $cityStateZip;
|
||||
}
|
||||
|
||||
return implode("\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked bank account number (last 4 digits).
|
||||
*/
|
||||
public function getMaskedAccountNumberAttribute(): ?string
|
||||
{
|
||||
if (! $this->bank_account_number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '****'.substr($this->bank_account_number, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked tax ID (last 4 digits).
|
||||
*/
|
||||
public function getMaskedTaxIdAttribute(): ?string
|
||||
{
|
||||
if (! $this->tax_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '***-**-'.substr($this->tax_id, -4);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Mark this DBA as the default for the business.
|
||||
*/
|
||||
public function markAsDefault(): void
|
||||
{
|
||||
// Clear default from other DBAs for this business
|
||||
static::where('business_id', $this->business_id)
|
||||
->where('id', '!=', $this->id)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
$this->update(['is_default' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display info for invoices/orders.
|
||||
*/
|
||||
public function getDisplayInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->trade_name,
|
||||
'address' => $this->full_address,
|
||||
'license' => $this->license_number,
|
||||
'logo' => $this->logo_path,
|
||||
'payment_terms' => $this->payment_terms,
|
||||
'payment_instructions' => $this->payment_instructions,
|
||||
'invoice_footer' => $this->invoice_footer,
|
||||
'primary_contact' => [
|
||||
'name' => $this->primary_contact_name,
|
||||
'email' => $this->primary_contact_email,
|
||||
'phone' => $this->primary_contact_phone,
|
||||
],
|
||||
'ap_contact' => [
|
||||
'name' => $this->ap_contact_name,
|
||||
'email' => $this->ap_contact_email,
|
||||
'phone' => $this->ap_contact_phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Boot
|
||||
// =========================================================================
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Auto-generate slug on creation
|
||||
static::creating(function ($dba) {
|
||||
if (empty($dba->slug)) {
|
||||
$dba->slug = Str::slug($dba->trade_name);
|
||||
|
||||
// Ensure unique
|
||||
$original = $dba->slug;
|
||||
$counter = 1;
|
||||
while (static::withTrashed()->where('slug', $dba->slug)->exists()) {
|
||||
$dba->slug = $original.'-'.$counter++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure only one default per business
|
||||
static::saving(function ($dba) {
|
||||
if ($dba->is_default && $dba->isDirty('is_default')) {
|
||||
static::where('business_id', $dba->business_id)
|
||||
->where('id', '!=', $dba->id ?? 0)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models\Crm;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use App\Models\BusinessLocation;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Order;
|
||||
@@ -55,6 +56,7 @@ class CrmInvoice extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'dba_id',
|
||||
'account_id',
|
||||
'location_id',
|
||||
'contact_id',
|
||||
@@ -110,6 +112,14 @@ class CrmInvoice extends Model
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The DBA (trade name) used for this invoice.
|
||||
*/
|
||||
public function dba(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BusinessDba::class, 'dba_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
@@ -400,4 +410,45 @@ class CrmInvoice extends Model
|
||||
|
||||
return $prefix.str_pad($number, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seller display information for the invoice.
|
||||
* Prioritizes DBA if set, otherwise falls back to business defaults.
|
||||
*/
|
||||
public function getSellerDisplayInfo(): array
|
||||
{
|
||||
if ($this->dba_id && $this->dba) {
|
||||
return $this->dba->getDisplayInfo();
|
||||
}
|
||||
|
||||
// Fall back to business info
|
||||
$business = $this->business;
|
||||
|
||||
return [
|
||||
'name' => $business->dba_name ?: $business->name,
|
||||
'address' => implode("\n", array_filter([
|
||||
$business->invoice_payable_address ?? $business->physical_address,
|
||||
trim(
|
||||
($business->invoice_payable_city ?? $business->physical_city ?? '').
|
||||
($business->invoice_payable_state ?? $business->physical_state ? ', '.($business->invoice_payable_state ?? $business->physical_state) : '').' '.
|
||||
($business->invoice_payable_zipcode ?? $business->physical_zipcode ?? '')
|
||||
),
|
||||
])),
|
||||
'license' => $business->license_number,
|
||||
'logo' => null,
|
||||
'payment_terms' => null,
|
||||
'payment_instructions' => $business->order_invoice_footer,
|
||||
'invoice_footer' => $business->order_invoice_footer,
|
||||
'primary_contact' => [
|
||||
'name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')),
|
||||
'email' => $business->primary_contact_email ?? $business->business_email,
|
||||
'phone' => $business->primary_contact_phone ?? $business->business_phone,
|
||||
],
|
||||
'ap_contact' => [
|
||||
'name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')),
|
||||
'email' => $business->ap_contact_email,
|
||||
'phone' => $business->ap_contact_phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class CrmInvoiceItem extends Model
|
||||
'tax_rate',
|
||||
'tax_amount',
|
||||
'line_total',
|
||||
'item_comment',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -53,13 +54,8 @@ class CrmInvoiceItem extends Model
|
||||
$item->calculateLineTotal();
|
||||
});
|
||||
|
||||
static::saved(function ($item) {
|
||||
$item->invoice->calculateTotals();
|
||||
});
|
||||
|
||||
static::deleted(function ($item) {
|
||||
$item->invoice->calculateTotals();
|
||||
});
|
||||
// NOTE: Invoice totals are recalculated explicitly in the controller after all items are saved
|
||||
// We don't auto-recalculate here to prevent lazy loading violations and duplicate calculations
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -28,6 +28,7 @@ class CrmQuoteItem extends Model
|
||||
'tax_rate',
|
||||
'line_total',
|
||||
'sort_order',
|
||||
'item_comment',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -45,13 +46,8 @@ class CrmQuoteItem extends Model
|
||||
$item->calculateLineTotal();
|
||||
});
|
||||
|
||||
static::saved(function ($item) {
|
||||
$item->quote->calculateTotals();
|
||||
});
|
||||
|
||||
static::deleted(function ($item) {
|
||||
$item->quote->calculateTotals();
|
||||
});
|
||||
// NOTE: Quote totals are recalculated explicitly in the controller after all items are saved
|
||||
// We don't auto-recalculate here to prevent lazy loading violations and duplicate calculations
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class Order extends Model implements Auditable
|
||||
'tax',
|
||||
'total',
|
||||
'status',
|
||||
'is_ping_pong',
|
||||
'created_by',
|
||||
'workorder_status',
|
||||
'payment_terms',
|
||||
@@ -98,6 +99,7 @@ class Order extends Model implements Auditable
|
||||
'surcharge' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'is_ping_pong' => 'boolean',
|
||||
'workorder_status' => 'decimal:2',
|
||||
'due_date' => 'date',
|
||||
'delivery_window_date' => 'date',
|
||||
|
||||
@@ -31,6 +31,7 @@ class OrderItem extends Model implements Auditable
|
||||
'product_name',
|
||||
'product_sku',
|
||||
'brand_name',
|
||||
'item_comment',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -265,6 +265,14 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* CannaiQ product mappings for this product.
|
||||
*/
|
||||
public function cannaiqMappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCannaiqMapping::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
@@ -517,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -762,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)
|
||||
@@ -1039,10 +1064,16 @@ class Product extends Model implements Auditable
|
||||
|
||||
/**
|
||||
* Get product story as sanitized HTML.
|
||||
*
|
||||
* Priority: consumer_long_description > buyer_long_description > long_description > description
|
||||
*/
|
||||
public function getStoryHtmlAttribute(): ?string
|
||||
{
|
||||
$text = $this->long_description ?? $this->description;
|
||||
$text = $this->consumer_long_description
|
||||
?? $this->buyer_long_description
|
||||
?? $this->long_description
|
||||
?? $this->description;
|
||||
|
||||
if (! $text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
36
app/Models/ProductCannaiqMapping.php
Normal file
36
app/Models/ProductCannaiqMapping.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Maps Hub products to CannaiQ products.
|
||||
*
|
||||
* Many-to-many relationship:
|
||||
* - One Hub product can map to multiple CannaiQ products
|
||||
* - One CannaiQ product can map to multiple Hub products
|
||||
*/
|
||||
class ProductCannaiqMapping extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'cannaiq_product_id',
|
||||
'cannaiq_product_name',
|
||||
'cannaiq_store_id',
|
||||
'cannaiq_store_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cannaiq_product_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* The Hub product this mapping belongs to.
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
164
app/Services/BannerAdService.php
Normal file
164
app/Services/BannerAdService.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\BannerAdStatus;
|
||||
use App\Enums\BannerAdZone;
|
||||
use App\Models\BannerAd;
|
||||
use App\Models\BannerAdEvent;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class BannerAdService
|
||||
{
|
||||
/**
|
||||
* Get active ads for a zone, optionally filtered by user context
|
||||
*/
|
||||
public function getAdsForZone(
|
||||
BannerAdZone $zone,
|
||||
?string $businessType = null,
|
||||
int $limit = 10
|
||||
): Collection {
|
||||
$cacheKey = "banner_ads:{$zone->value}:".($businessType ?? 'all');
|
||||
|
||||
return Cache::remember($cacheKey, 300, function () use ($zone, $businessType) {
|
||||
$query = BannerAd::active()
|
||||
->forZone($zone)
|
||||
->forBusinessType($businessType)
|
||||
->orderByDesc('priority')
|
||||
->orderByDesc('weight');
|
||||
|
||||
return $query->get();
|
||||
})->take($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ad for display with weighted random selection
|
||||
*/
|
||||
public function getAdForZone(
|
||||
BannerAdZone $zone,
|
||||
?string $businessType = null
|
||||
): ?BannerAd {
|
||||
$ads = $this->getAdsForZone($zone, $businessType, 10);
|
||||
|
||||
if ($ads->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If only one ad, return it
|
||||
if ($ads->count() === 1) {
|
||||
return $ads->first();
|
||||
}
|
||||
|
||||
// Weighted random selection
|
||||
$totalWeight = $ads->sum('weight');
|
||||
$random = rand(1, $totalWeight);
|
||||
$currentWeight = 0;
|
||||
|
||||
foreach ($ads as $ad) {
|
||||
$currentWeight += $ad->weight;
|
||||
if ($random <= $currentWeight) {
|
||||
return $ad;
|
||||
}
|
||||
}
|
||||
|
||||
return $ads->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an impression event (fire-and-forget)
|
||||
*/
|
||||
public function recordImpression(BannerAd $ad, array $context = []): void
|
||||
{
|
||||
// Fire-and-forget to avoid blocking page load
|
||||
dispatch(function () use ($ad, $context) {
|
||||
BannerAdEvent::create([
|
||||
'banner_ad_id' => $ad->id,
|
||||
'business_id' => $context['business_id'] ?? null,
|
||||
'user_id' => $context['user_id'] ?? null,
|
||||
'event_type' => 'impression',
|
||||
'session_id' => $context['session_id'] ?? session()->getId(),
|
||||
'ip_address' => $context['ip_address'] ?? request()->ip(),
|
||||
'user_agent' => $context['user_agent'] ?? request()->userAgent(),
|
||||
'page_url' => $context['page_url'] ?? request()->fullUrl(),
|
||||
'referer' => $context['referer'] ?? request()->header('referer'),
|
||||
]);
|
||||
|
||||
$ad->incrementImpressions();
|
||||
})->afterResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a click event
|
||||
*/
|
||||
public function recordClick(BannerAd $ad, array $context = []): void
|
||||
{
|
||||
BannerAdEvent::create([
|
||||
'banner_ad_id' => $ad->id,
|
||||
'business_id' => $context['business_id'] ?? null,
|
||||
'user_id' => $context['user_id'] ?? null,
|
||||
'event_type' => 'click',
|
||||
'session_id' => $context['session_id'] ?? session()->getId(),
|
||||
'ip_address' => $context['ip_address'] ?? request()->ip(),
|
||||
'user_agent' => $context['user_agent'] ?? request()->userAgent(),
|
||||
'page_url' => $context['page_url'] ?? null,
|
||||
'referer' => $context['referer'] ?? request()->header('referer'),
|
||||
]);
|
||||
|
||||
$ad->incrementClicks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a zone
|
||||
*/
|
||||
public function clearZoneCache(BannerAdZone $zone): void
|
||||
{
|
||||
Cache::forget("banner_ads:{$zone->value}:all");
|
||||
Cache::forget("banner_ads:{$zone->value}:buyer");
|
||||
Cache::forget("banner_ads:{$zone->value}:seller");
|
||||
Cache::forget("banner_ads:{$zone->value}:both");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all banner ad caches
|
||||
*/
|
||||
public function clearAllCaches(): void
|
||||
{
|
||||
foreach (BannerAdZone::cases() as $zone) {
|
||||
$this->clearZoneCache($zone);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ad statuses based on schedule
|
||||
*
|
||||
* @return int Number of ads updated
|
||||
*/
|
||||
public function updateScheduledStatuses(): int
|
||||
{
|
||||
$now = now();
|
||||
$updated = 0;
|
||||
|
||||
// Activate scheduled ads that have started
|
||||
$updated += BannerAd::where('status', BannerAdStatus::SCHEDULED)
|
||||
->whereNotNull('starts_at')
|
||||
->where('starts_at', '<=', $now)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('ends_at')->orWhere('ends_at', '>', $now);
|
||||
})
|
||||
->update(['status' => BannerAdStatus::ACTIVE]);
|
||||
|
||||
// Expire active ads that have ended
|
||||
$updated += BannerAd::where('status', BannerAdStatus::ACTIVE)
|
||||
->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', $now)
|
||||
->update(['status' => BannerAdStatus::EXPIRED]);
|
||||
|
||||
// Clear caches if any ads were updated
|
||||
if ($updated > 0) {
|
||||
$this->clearAllCaches();
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
}
|
||||
135
app/Services/ProductComparisonService.php
Normal file
135
app/Services/ProductComparisonService.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Service for managing product comparison list.
|
||||
*
|
||||
* Stores selected product IDs in the session for side-by-side comparison.
|
||||
*/
|
||||
class ProductComparisonService
|
||||
{
|
||||
private const SESSION_KEY = 'compare_products';
|
||||
|
||||
private const MAX_ITEMS = 4; // Maximum products to compare at once
|
||||
|
||||
/**
|
||||
* Add a product to comparison list.
|
||||
*/
|
||||
public function add(int $productId): bool
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
|
||||
if (count($ids) >= self::MAX_ITEMS) {
|
||||
return false; // List is full
|
||||
}
|
||||
|
||||
if (in_array($productId, $ids)) {
|
||||
return true; // Already in list
|
||||
}
|
||||
|
||||
$ids[] = $productId;
|
||||
session()->put(self::SESSION_KEY, $ids);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from comparison list.
|
||||
*/
|
||||
public function remove(int $productId): void
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
$ids = array_values(array_filter($ids, fn ($id) => $id !== $productId));
|
||||
session()->put(self::SESSION_KEY, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product in the comparison list.
|
||||
*
|
||||
* @return array{added: bool, count: int}
|
||||
*/
|
||||
public function toggle(int $productId): array
|
||||
{
|
||||
if ($this->isInList($productId)) {
|
||||
$this->remove($productId);
|
||||
|
||||
return ['added' => false, 'count' => $this->count()];
|
||||
}
|
||||
|
||||
$added = $this->add($productId);
|
||||
|
||||
return ['added' => $added, 'count' => $this->count()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product is in the comparison list.
|
||||
*/
|
||||
public function isInList(int $productId): bool
|
||||
{
|
||||
return in_array($productId, $this->getProductIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product IDs in comparison list.
|
||||
*/
|
||||
public function getProductIds(): array
|
||||
{
|
||||
return session()->get(self::SESSION_KEY, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products with full model data for comparison.
|
||||
*/
|
||||
public function getProducts(): Collection
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Product::query()
|
||||
->with(['brand:id,name,slug', 'strain:id,name,type', 'category:id,name'])
|
||||
->whereIn('id', $ids)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the comparison list.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of products in comparison list.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->getProductIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if list is full.
|
||||
*/
|
||||
public function isFull(): bool
|
||||
{
|
||||
return $this->count() >= self::MAX_ITEMS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed items.
|
||||
*/
|
||||
public function maxItems(): int
|
||||
{
|
||||
return self::MAX_ITEMS;
|
||||
}
|
||||
}
|
||||
111
app/Services/RecentlyViewedService.php
Normal file
111
app/Services/RecentlyViewedService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Service for tracking and retrieving recently viewed products.
|
||||
*
|
||||
* Stores product IDs in the session with timestamps, limited to the most recent 20 products.
|
||||
*/
|
||||
class RecentlyViewedService
|
||||
{
|
||||
private const SESSION_KEY = 'recently_viewed_products';
|
||||
|
||||
private const MAX_ITEMS = 20;
|
||||
|
||||
/**
|
||||
* Record a product view.
|
||||
*/
|
||||
public function recordView(int $productId): void
|
||||
{
|
||||
$viewed = session()->get(self::SESSION_KEY, []);
|
||||
|
||||
// Remove if already exists (we'll re-add with new timestamp)
|
||||
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $productId);
|
||||
|
||||
// Add to beginning of array
|
||||
array_unshift($viewed, [
|
||||
'id' => $productId,
|
||||
'viewed_at' => now()->timestamp,
|
||||
]);
|
||||
|
||||
// Limit to max items
|
||||
$viewed = array_slice($viewed, 0, self::MAX_ITEMS);
|
||||
|
||||
session()->put(self::SESSION_KEY, $viewed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently viewed product IDs (most recent first).
|
||||
*
|
||||
* @param int|null $limit Limit results (default: all)
|
||||
* @param int|null $excludeId Exclude a specific product ID
|
||||
*/
|
||||
public function getProductIds(?int $limit = null, ?int $excludeId = null): array
|
||||
{
|
||||
$viewed = session()->get(self::SESSION_KEY, []);
|
||||
|
||||
if ($excludeId) {
|
||||
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $excludeId);
|
||||
}
|
||||
|
||||
$ids = array_column(array_values($viewed), 'id');
|
||||
|
||||
if ($limit) {
|
||||
$ids = array_slice($ids, 0, $limit);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently viewed products with full model data.
|
||||
*
|
||||
* @param int $limit Maximum number of products to return
|
||||
* @param int|null $excludeId Exclude a specific product ID (e.g., current product)
|
||||
*/
|
||||
public function getProducts(int $limit = 8, ?int $excludeId = null): Collection
|
||||
{
|
||||
$ids = $this->getProductIds($limit + 1, $excludeId); // Get extra to handle filter
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Fetch products in the order they were viewed
|
||||
$products = Product::query()
|
||||
->with('brand:id,name,slug')
|
||||
->whereIn('id', $ids)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Sort by the original order and limit
|
||||
$idOrder = array_flip($ids);
|
||||
|
||||
return $products
|
||||
->sortBy(fn ($p) => $idOrder[$p->id] ?? PHP_INT_MAX)
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recently viewed history.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of recently viewed products.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count(session()->get(self::SESSION_KEY, []));
|
||||
}
|
||||
}
|
||||
63
app/View/Components/BannerAd.php
Normal file
63
app/View/Components/BannerAd.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use App\Enums\BannerAdZone;
|
||||
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
|
||||
{
|
||||
public ?BannerAdModel $ad = null;
|
||||
|
||||
public BannerAdZone $zone;
|
||||
|
||||
public array $dimensions;
|
||||
|
||||
public function __construct(
|
||||
string $zone,
|
||||
) {
|
||||
$this->zone = BannerAdZone::from($zone);
|
||||
$this->dimensions = $this->zone->dimensions();
|
||||
|
||||
// Skip if banner_ads table doesn't exist (migrations not run)
|
||||
if (! Schema::hasTable('banner_ads')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get business type from authenticated user
|
||||
$businessType = auth()->user()?->user_type;
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
return $this->ad !== null;
|
||||
}
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.banner-ad');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?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::create('business_dbas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained('businesses')->onDelete('cascade');
|
||||
|
||||
// Identity
|
||||
$table->string('trade_name');
|
||||
$table->string('slug')->unique();
|
||||
|
||||
// Address
|
||||
$table->string('address')->nullable();
|
||||
$table->string('address_line_2')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('state', 2)->nullable();
|
||||
$table->string('zip', 10)->nullable();
|
||||
|
||||
// License
|
||||
$table->string('license_number')->nullable();
|
||||
$table->string('license_type')->nullable();
|
||||
$table->date('license_expiration')->nullable();
|
||||
|
||||
// Bank Info (encrypted at model level)
|
||||
$table->string('bank_name')->nullable();
|
||||
$table->string('bank_account_name')->nullable();
|
||||
$table->text('bank_routing_number')->nullable();
|
||||
$table->text('bank_account_number')->nullable();
|
||||
$table->string('bank_account_type', 50)->nullable();
|
||||
|
||||
// Tax
|
||||
$table->text('tax_id')->nullable();
|
||||
$table->string('tax_id_type', 50)->nullable();
|
||||
|
||||
// Contacts
|
||||
$table->string('primary_contact_name')->nullable();
|
||||
$table->string('primary_contact_email')->nullable();
|
||||
$table->string('primary_contact_phone', 50)->nullable();
|
||||
$table->string('ap_contact_name')->nullable();
|
||||
$table->string('ap_contact_email')->nullable();
|
||||
$table->string('ap_contact_phone', 50)->nullable();
|
||||
|
||||
// Invoice Settings
|
||||
$table->string('payment_terms', 50)->nullable();
|
||||
$table->text('payment_instructions')->nullable();
|
||||
$table->text('invoice_footer')->nullable();
|
||||
$table->string('invoice_prefix', 10)->nullable();
|
||||
|
||||
// Branding
|
||||
$table->string('logo_path')->nullable();
|
||||
$table->jsonb('brand_colors')->nullable();
|
||||
|
||||
// Status
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index('business_id');
|
||||
$table->index(['business_id', 'is_default']);
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_dbas');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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_invoices', function (Blueprint $table) {
|
||||
$table->foreignId('dba_id')
|
||||
->nullable()
|
||||
->after('business_id')
|
||||
->constrained('business_dbas')
|
||||
->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['dba_id']);
|
||||
$table->dropColumn('dba_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add item_comment to invoice line items
|
||||
if (Schema::hasTable('crm_invoice_items') && !Schema::hasColumn('crm_invoice_items', 'item_comment')) {
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->text('item_comment')->nullable()->after('discount_percent');
|
||||
});
|
||||
}
|
||||
|
||||
// Add item_comment to quote line items
|
||||
if (Schema::hasTable('crm_quote_items') && !Schema::hasColumn('crm_quote_items', 'item_comment')) {
|
||||
Schema::table('crm_quote_items', function (Blueprint $table) {
|
||||
$table->text('item_comment')->nullable()->after('discount_percent');
|
||||
});
|
||||
}
|
||||
|
||||
// Add item_comment to order items (if not exists)
|
||||
if (Schema::hasTable('order_items') && !Schema::hasColumn('order_items', 'item_comment')) {
|
||||
Schema::table('order_items', function (Blueprint $table) {
|
||||
$table->text('item_comment')->nullable()->after('notes');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('crm_invoice_items', 'item_comment')) {
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->dropColumn('item_comment');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('crm_quote_items', 'item_comment')) {
|
||||
Schema::table('crm_quote_items', function (Blueprint $table) {
|
||||
$table->dropColumn('item_comment');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('order_items', 'item_comment')) {
|
||||
Schema::table('order_items', function (Blueprint $table) {
|
||||
$table->dropColumn('item_comment');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->boolean('ping_pong_enabled')->default(false)->after('is_enterprise_plan');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropColumn('ping_pong_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->boolean('is_ping_pong')->default(false)->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn('is_ping_pong');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Fix crm_invoices and crm_invoice_items schema issues.
|
||||
*
|
||||
* Issues:
|
||||
* 1. crm_invoices: missing location_id column (controller tries to insert it)
|
||||
* 2. crm_invoice_items: 'name' column is NOT NULL but controller doesn't provide it
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add location_id to crm_invoices if it doesn't exist
|
||||
if (! Schema::hasColumn('crm_invoices', 'location_id')) {
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->foreignId('location_id')->nullable()->after('account_id')->constrained('locations')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
// Make name nullable in crm_invoice_items
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->string('name')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('crm_invoices', 'location_id')) {
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['location_id']);
|
||||
$table->dropColumn('location_id');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->string('name')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add cannaiq_brand_key column to brands table.
|
||||
*
|
||||
* This stores the normalized brand name used to query CannaIQ API.
|
||||
* Example: "Aloha TymeMachine" → "alohatymemachine"
|
||||
*
|
||||
* Security: This key is used to filter ALL CannaIQ API calls to only
|
||||
* return data for this brand. Brands cannot see competitor data.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->string('cannaiq_brand_key')->nullable()->after('inbound_email_channel_id');
|
||||
$table->index('cannaiq_brand_key');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->dropIndex(['cannaiq_brand_key']);
|
||||
$table->dropColumn('cannaiq_brand_key');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banner_ads', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Ownership: null = platform-wide, brand_id = brand-specific ad
|
||||
$table->foreignId('brand_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('created_by_user_id')->constrained('users');
|
||||
|
||||
// Content
|
||||
$table->string('name'); // Internal name for admin
|
||||
$table->string('headline')->nullable(); // Overlay headline
|
||||
$table->text('description')->nullable(); // Overlay description
|
||||
$table->string('cta_text', 50)->nullable(); // Button text (e.g., "Shop Now")
|
||||
$table->string('cta_url', 500); // Click destination URL
|
||||
|
||||
// Image - stored in MinIO
|
||||
$table->string('image_path'); // Full MinIO path
|
||||
$table->string('image_alt')->nullable(); // Alt text for accessibility
|
||||
|
||||
// Placement & Dimensions
|
||||
$table->string('zone', 50)->index(); // BannerAdZone enum value
|
||||
|
||||
// Scheduling
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
|
||||
// Targeting
|
||||
$table->json('target_business_types')->nullable(); // ['buyer', 'seller', 'both']
|
||||
$table->boolean('is_platform_wide')->default(true);
|
||||
|
||||
// Status
|
||||
$table->string('status', 20)->default('draft'); // draft, active, scheduled, paused, expired
|
||||
|
||||
// Priority for rotation (higher = shown more often)
|
||||
$table->integer('priority')->default(0);
|
||||
$table->integer('weight')->default(100); // For weighted random selection (1-1000)
|
||||
|
||||
// Stats (denormalized for fast reads)
|
||||
$table->unsignedBigInteger('impressions')->default(0);
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index(['zone', 'status']);
|
||||
$table->index(['status', 'starts_at', 'ends_at']);
|
||||
$table->index(['brand_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banner_ads');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banner_ad_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('banner_ad_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('business_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->string('event_type', 20)->index(); // impression, click
|
||||
|
||||
// Context
|
||||
$table->string('session_id', 100)->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->string('page_url', 500)->nullable(); // Where ad was shown
|
||||
$table->string('referer', 500)->nullable();
|
||||
|
||||
$table->timestamp('created_at')->index();
|
||||
|
||||
// Indexes for reporting
|
||||
$table->index(['banner_ad_id', 'event_type', 'created_at']);
|
||||
$table->index(['created_at', 'event_type']); // For daily rollups
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banner_ad_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banner_ad_daily_stats', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('banner_ad_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('date')->index();
|
||||
|
||||
$table->unsignedInteger('impressions')->default(0);
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_impressions')->default(0);
|
||||
$table->unsignedInteger('unique_clicks')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['banner_ad_id', 'date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banner_ad_daily_stats');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Create product_cannaiq_mappings pivot table.
|
||||
*
|
||||
* Maps Hub products to CannaiQ products (many-to-many).
|
||||
* - One Hub product can map to multiple CannaiQ products (same product at different dispensaries)
|
||||
* - One CannaiQ product can map to multiple Hub products (bundles, variants)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_cannaiq_mappings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained()->onDelete('cascade');
|
||||
$table->bigInteger('cannaiq_product_id'); // CannaiQ product ID
|
||||
$table->string('cannaiq_product_name'); // Denormalized for display
|
||||
$table->string('cannaiq_store_id')->nullable(); // Optional store-specific mapping
|
||||
$table->string('cannaiq_store_name')->nullable(); // Denormalized store name
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['product_id', 'cannaiq_product_id'], 'product_cannaiq_unique');
|
||||
$table->index('cannaiq_product_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_cannaiq_mappings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Merge duplicate parent categories - keep the one with more products,
|
||||
* reassign products and children from the duplicate.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Pairs: [keep, delete] - keep the one with more products
|
||||
$merges = [
|
||||
// Accessories: both have 0 products, keep lower ID
|
||||
[41, 86],
|
||||
// Concentrates: 16 has 15, 61 has 1
|
||||
[16, 61],
|
||||
// Tinctures: 37 has 7, 82 has 0
|
||||
[37, 82],
|
||||
// Vapes: 56 has 157, 11 has 0
|
||||
[56, 11],
|
||||
// Pre-Rolls: 52 has 3, 7 has 0
|
||||
[52, 7],
|
||||
// Edibles: 70 has 6, 25 has 3
|
||||
[70, 25],
|
||||
// Flower: both have 0, keep lower ID
|
||||
[1, 46],
|
||||
// Topicals: 32 has 34, 77 has 4
|
||||
[32, 77],
|
||||
];
|
||||
|
||||
foreach ($merges as [$keepId, $deleteId]) {
|
||||
// Move products from duplicate to keeper
|
||||
DB::table('products')
|
||||
->where('category_id', $deleteId)
|
||||
->update(['category_id' => $keepId]);
|
||||
|
||||
// Move child categories from duplicate to keeper
|
||||
DB::table('product_categories')
|
||||
->where('parent_id', $deleteId)
|
||||
->update(['parent_id' => $keepId]);
|
||||
|
||||
// Soft delete the duplicate (or hard delete if no soft deletes)
|
||||
DB::table('product_categories')
|
||||
->where('id', $deleteId)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Re-activate the deactivated categories
|
||||
$reactivate = [86, 61, 82, 11, 7, 25, 46, 77];
|
||||
|
||||
DB::table('product_categories')
|
||||
->whereIn('id', $reactivate)
|
||||
->update(['is_active' => true]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Assign White Label Canna products to Bulk category and mark as raw materials.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// White Label Canna brand ID = 18, Bulk category ID = 147
|
||||
$brandId = 18;
|
||||
$bulkCategoryId = 147;
|
||||
|
||||
// Update all products from this brand
|
||||
DB::table('products')
|
||||
->where('brand_id', $brandId)
|
||||
->update([
|
||||
'category_id' => $bulkCategoryId,
|
||||
'is_raw_material' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Revert - set back to no category and not raw material
|
||||
$brandId = 18;
|
||||
|
||||
DB::table('products')
|
||||
->where('brand_id', $brandId)
|
||||
->update([
|
||||
'category_id' => null,
|
||||
'is_raw_material' => false,
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="pgsql"/>
|
||||
<env name="DB_HOST" value="pgsql"/>
|
||||
<env name="DB_PORT" value="5432"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
|
||||
11
public/sw.js
11
public/sw.js
@@ -1,6 +1,17 @@
|
||||
// Cannabrands Hub Service Worker
|
||||
// Uses Workbox for caching strategies and update detection
|
||||
|
||||
// LOCALHOST SELF-DESTRUCT: Unregister immediately on localhost to avoid dev issues
|
||||
if (self.location.hostname === 'localhost' || self.location.hostname === '127.0.0.1') {
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', () => {
|
||||
self.registration.unregister().then(() => {
|
||||
console.log('SW self-destructed on localhost');
|
||||
});
|
||||
});
|
||||
throw new Error('SW disabled on localhost'); // Stop execution here
|
||||
}
|
||||
|
||||
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
|
||||
|
||||
// Cache version - update this to trigger a new SW install
|
||||
|
||||
391
resources/views/buyer/buy-again/index.blade.php
Normal file
391
resources/views/buyer/buy-again/index.blade.php
Normal file
@@ -0,0 +1,391 @@
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('title', 'Buy It Again - ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div x-data="buyAgain()" x-init="init()">
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Buy It Again</h1>
|
||||
<p class="text-base-content/60">Quickly reorder from your favorite brands</p>
|
||||
</div>
|
||||
<button class="btn btn-warning gap-2"
|
||||
@click="addAllToCart()"
|
||||
:disabled="!hasItemsWithQuantity()"
|
||||
:class="{ 'btn-disabled': !hasItemsWithQuantity() }">
|
||||
<span class="icon-[heroicons--shopping-cart] size-5"></span>
|
||||
Add all to cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tabs --}}
|
||||
<div class="tabs tabs-boxed mb-6 inline-flex">
|
||||
<a href="{{ route('buyer.business.buy-again', ['business' => $business->slug, 'tab' => 'favorites']) }}"
|
||||
class="tab {{ $tab === 'favorites' ? 'tab-active' : '' }}">
|
||||
<span class="icon-[heroicons--heart] size-4 mr-2"></span>
|
||||
Store favorites
|
||||
</a>
|
||||
<a href="{{ route('buyer.business.buy-again', ['business' => $business->slug, 'tab' => 'history']) }}"
|
||||
class="tab {{ $tab === 'history' ? 'tab-active' : '' }}">
|
||||
<span class="icon-[heroicons--clock] size-4 mr-2"></span>
|
||||
Purchase history
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Search --}}
|
||||
<div class="form-control mb-6">
|
||||
<div class="relative w-full max-w-xs">
|
||||
<input type="text"
|
||||
placeholder="Search products..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
x-model="search"
|
||||
@input.debounce.300ms="filterProducts()">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Empty State --}}
|
||||
@if($brands->isEmpty())
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body items-center text-center py-16">
|
||||
@if($tab === 'favorites')
|
||||
<span class="icon-[heroicons--heart] size-16 text-base-content/20 mb-4"></span>
|
||||
<h3 class="text-xl font-semibold mb-2">No favorite brands yet</h3>
|
||||
<p class="text-base-content/60 mb-4">Follow brands to see their products here for quick reordering.</p>
|
||||
<a href="{{ route('buyer.brands.index') }}" class="btn btn-primary">
|
||||
<span class="icon-[heroicons--building-storefront] size-4"></span>
|
||||
Browse Brands
|
||||
</a>
|
||||
@else
|
||||
<span class="icon-[heroicons--shopping-bag] size-16 text-base-content/20 mb-4"></span>
|
||||
<h3 class="text-xl font-semibold mb-2">No purchase history</h3>
|
||||
<p class="text-base-content/60 mb-4">Place your first order to see your purchase history here.</p>
|
||||
<a href="{{ route('buyer.browse') }}" class="btn btn-primary">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Start Shopping
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
|
||||
{{-- Brands (Collapsible) --}}
|
||||
@foreach($brands as $brand)
|
||||
<div class="collapse collapse-arrow bg-base-100 border border-base-200 mb-4 shadow-sm"
|
||||
x-show="brandMatchesSearch('{{ addslashes($brand->name) }}', {{ $brand->products->pluck('name')->map(fn($n) => addslashes($n))->toJson() }})">
|
||||
<input type="checkbox" checked>
|
||||
<div class="collapse-title flex items-center gap-3 pe-12">
|
||||
@if($brand->getLogoUrl('thumb'))
|
||||
<img src="{{ $brand->getLogoUrl('thumb') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="w-10 h-10 rounded object-cover bg-base-200">
|
||||
@else
|
||||
<div class="w-10 h-10 rounded bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-primary font-bold">{{ substr($brand->name, 0, 2) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<span class="font-semibold">{{ $brand->name }}</span>
|
||||
<span class="badge badge-ghost">{{ $brand->products->count() }} products</span>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="min-w-[250px]">Product</th>
|
||||
<th>Price</th>
|
||||
<th>Last Ordered</th>
|
||||
@if($storeMetrics)
|
||||
<th>In Stock</th>
|
||||
<th>Days Left</th>
|
||||
@endif
|
||||
<th class="w-36">Quantity</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($brand->products as $product)
|
||||
<tr x-show="productMatchesSearch('{{ addslashes($product->name) }}', '{{ addslashes($product->sku ?? '') }}')"
|
||||
class="hover:bg-base-200/50">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($product->getImageUrl('thumb'))
|
||||
<img src="{{ $product->getImageUrl('thumb') }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-12 h-12 rounded object-cover bg-base-200">
|
||||
@else
|
||||
<div class="w-12 h-12 rounded bg-base-200 flex items-center justify-center">
|
||||
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="font-medium">{{ $product->name }}</div>
|
||||
@if($product->sku)
|
||||
<div class="text-xs text-base-content/50 font-mono">
|
||||
{{ $product->sku }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-medium">
|
||||
${{ number_format($product->wholesale_price ?? $product->price ?? 0, 2) }}
|
||||
@if($product->unit_type)
|
||||
<span class="text-xs text-base-content/60">/{{ $product->unit_type }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-base-content/70">
|
||||
@php
|
||||
$lastOrder = $product->orderItems->first()?->order;
|
||||
@endphp
|
||||
{{ $lastOrder?->created_at?->format('M Y') ?? '-' }}
|
||||
</td>
|
||||
@if($storeMetrics)
|
||||
<td>
|
||||
@if(isset($storeMetrics[$product->id]['on_hand']))
|
||||
<span class="font-medium">{{ number_format($storeMetrics[$product->id]['on_hand']) }}</span>
|
||||
@else
|
||||
<span class="text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if(isset($storeMetrics[$product->id]['days_until_out']))
|
||||
@php $daysLeft = $storeMetrics[$product->id]['days_until_out']; @endphp
|
||||
@if($daysLeft < 7)
|
||||
<span class="badge badge-error badge-sm">
|
||||
{{ $daysLeft }} days
|
||||
</span>
|
||||
@elseif($daysLeft < 14)
|
||||
<span class="badge badge-warning badge-sm">
|
||||
{{ $daysLeft }} days
|
||||
</span>
|
||||
@else
|
||||
<span class="text-success">{{ $daysLeft }} days</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td>
|
||||
<div class="join">
|
||||
<button class="btn btn-sm join-item"
|
||||
@click="decrementQty({{ $product->id }})"
|
||||
:disabled="getQty({{ $product->id }}) <= 0">
|
||||
<span class="icon-[heroicons--minus] size-4"></span>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="input input-sm input-bordered w-16 join-item text-center"
|
||||
:value="getQty({{ $product->id }})"
|
||||
@change="setQty({{ $product->id }}, $event.target.value)"
|
||||
min="0">
|
||||
<button class="btn btn-sm join-item"
|
||||
@click="incrementQty({{ $product->id }})">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($product->is_active && ($product->stock_quantity ?? 1) > 0)
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@click="addToCart({{ $product->id }})"
|
||||
:disabled="getQty({{ $product->id }}) <= 0"
|
||||
:class="{ 'btn-disabled': getQty({{ $product->id }}) <= 0 }">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add
|
||||
</button>
|
||||
@else
|
||||
<span class="badge badge-ghost">Unavailable</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function buyAgain() {
|
||||
return {
|
||||
search: '',
|
||||
quantities: {},
|
||||
cart: {},
|
||||
|
||||
init() {
|
||||
// Initialize quantities to 1 for all products
|
||||
@foreach($brands as $brand)
|
||||
@foreach($brand->products as $product)
|
||||
this.quantities[{{ $product->id }}] = 1;
|
||||
@endforeach
|
||||
@endforeach
|
||||
|
||||
this.loadCartState();
|
||||
},
|
||||
|
||||
async loadCartState() {
|
||||
try {
|
||||
const response = await fetch('{{ route("buyer.business.cart.index", $business->slug) }}', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
if (data.items && Array.isArray(data.items)) {
|
||||
data.items.forEach(item => {
|
||||
this.cart[item.product_id] = {
|
||||
cartId: item.id,
|
||||
quantity: item.quantity
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading cart:', error);
|
||||
}
|
||||
},
|
||||
|
||||
getQty(productId) {
|
||||
return this.quantities[productId] || 0;
|
||||
},
|
||||
|
||||
setQty(productId, value) {
|
||||
this.quantities[productId] = Math.max(0, parseInt(value) || 0);
|
||||
},
|
||||
|
||||
incrementQty(productId) {
|
||||
this.quantities[productId] = (this.quantities[productId] || 0) + 1;
|
||||
},
|
||||
|
||||
decrementQty(productId) {
|
||||
if (this.quantities[productId] > 0) {
|
||||
this.quantities[productId]--;
|
||||
}
|
||||
},
|
||||
|
||||
hasItemsWithQuantity() {
|
||||
return Object.values(this.quantities).some(qty => qty > 0);
|
||||
},
|
||||
|
||||
filterProducts() {
|
||||
// Re-render handled by Alpine x-show directives
|
||||
},
|
||||
|
||||
brandMatchesSearch(brandName, productNames) {
|
||||
if (!this.search) return true;
|
||||
const searchLower = this.search.toLowerCase();
|
||||
if (brandName.toLowerCase().includes(searchLower)) return true;
|
||||
return productNames.some(name => name.toLowerCase().includes(searchLower));
|
||||
},
|
||||
|
||||
productMatchesSearch(productName, sku) {
|
||||
if (!this.search) return true;
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return productName.toLowerCase().includes(searchLower) ||
|
||||
(sku && sku.toLowerCase().includes(searchLower));
|
||||
},
|
||||
|
||||
async addToCart(productId) {
|
||||
const quantity = this.quantities[productId] || 1;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', quantity);
|
||||
|
||||
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
this.cart[productId] = {
|
||||
cartId: data.cart_item.id,
|
||||
quantity: data.cart_item.quantity
|
||||
};
|
||||
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
|
||||
window.showToast?.('Added to cart', 'success') ||
|
||||
alert('Added to cart!');
|
||||
} else {
|
||||
window.showToast?.(data.message || 'Failed to add to cart', 'error') ||
|
||||
alert(data.message || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Failed to add product to cart';
|
||||
window.showToast?.(message, 'error') || alert(message);
|
||||
}
|
||||
},
|
||||
|
||||
getCartCount() {
|
||||
return Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0);
|
||||
},
|
||||
|
||||
async addAllToCart() {
|
||||
const productsToAdd = Object.entries(this.quantities)
|
||||
.filter(([_, qty]) => qty > 0);
|
||||
|
||||
if (productsToAdd.length === 0) {
|
||||
window.showToast?.('No products selected', 'warning') ||
|
||||
alert('No products selected');
|
||||
return;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const [productId, quantity] of productsToAdd) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', quantity);
|
||||
|
||||
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
|
||||
|
||||
if (response.data.success) {
|
||||
this.cart[productId] = {
|
||||
cartId: response.data.cart_item.id,
|
||||
quantity: response.data.cart_item.quantity
|
||||
};
|
||||
addedCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
|
||||
if (addedCount > 0 && failedCount === 0) {
|
||||
window.showToast?.(`Added ${addedCount} items to cart`, 'success') ||
|
||||
alert(`Added ${addedCount} items to cart`);
|
||||
} else if (addedCount > 0 && failedCount > 0) {
|
||||
window.showToast?.(`Added ${addedCount} items, ${failedCount} failed`, 'warning') ||
|
||||
alert(`Added ${addedCount} items, ${failedCount} failed`);
|
||||
} else {
|
||||
window.showToast?.('Failed to add items to cart', 'error') ||
|
||||
alert('Failed to add items to cart');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
361
resources/views/buyer/compare/index.blade.php
Normal file
361
resources/views/buyer/compare/index.blade.php
Normal file
@@ -0,0 +1,361 @@
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('title', 'Compare Products - ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen py-6" x-data="compareProducts()">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[heroicons--scale] size-7 text-primary"></span>
|
||||
Compare Products
|
||||
</h1>
|
||||
<p class="text-base-content/60 mt-1">Compare up to 4 products side-by-side</p>
|
||||
</div>
|
||||
|
||||
@if($products->count() > 0)
|
||||
<button @click="clearAll()"
|
||||
class="btn btn-ghost btn-sm gap-2">
|
||||
<span class="icon-[heroicons--trash] size-4"></span>
|
||||
Clear All
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($products->count() === 0)
|
||||
{{-- Empty State --}}
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body text-center py-16">
|
||||
<span class="icon-[heroicons--scale] size-20 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No products to compare</h3>
|
||||
<p class="text-base-content/50 mb-6 max-w-md mx-auto">
|
||||
Add products to compare by clicking the compare button on product cards while browsing.
|
||||
</p>
|
||||
<a href="{{ route('buyer.browse') }}" class="btn btn-primary gap-2">
|
||||
<span class="icon-[heroicons--shopping-bag] size-5"></span>
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Comparison Table --}}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table bg-base-100 shadow-lg rounded-box">
|
||||
{{-- Product Images Row --}}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-48 bg-base-200 sticky left-0 z-10">Product</th>
|
||||
@foreach($products as $product)
|
||||
<th class="min-w-[250px] text-center p-0">
|
||||
<div class="relative">
|
||||
{{-- Remove Button --}}
|
||||
<button @click="removeProduct({{ $product->id }})"
|
||||
class="btn btn-xs btn-circle btn-ghost absolute top-2 right-2 z-10 bg-base-100/80 hover:bg-error hover:text-error-content">
|
||||
<span class="icon-[heroicons--x-mark] size-4"></span>
|
||||
</button>
|
||||
|
||||
{{-- Product Image --}}
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="block p-4">
|
||||
<div class="aspect-square w-full max-w-[200px] mx-auto bg-base-200 rounded-lg overflow-hidden">
|
||||
@if($product->getImageUrl('medium'))
|
||||
<img src="{{ $product->getImageUrl('medium') }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-full h-full object-cover">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="icon-[heroicons--cube] size-16 text-base-content/20"></span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</th>
|
||||
@endforeach
|
||||
|
||||
{{-- Add Product Slot (if under max) --}}
|
||||
@if($products->count() < 4)
|
||||
<th class="min-w-[200px] text-center">
|
||||
<a href="{{ route('buyer.browse') }}"
|
||||
class="block p-4">
|
||||
<div class="aspect-square w-full max-w-[180px] mx-auto border-2 border-dashed border-base-300 rounded-lg flex flex-col items-center justify-center hover:border-primary hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[heroicons--plus] size-10 text-base-content/30"></span>
|
||||
<span class="text-sm text-base-content/50 mt-2">Add Product</span>
|
||||
</div>
|
||||
</a>
|
||||
</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{-- Product Name --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Name</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="font-semibold hover:text-primary">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Brand --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Brand</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->brand)
|
||||
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}"
|
||||
class="link link-primary">
|
||||
{{ $product->brand->name }}
|
||||
</a>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Price --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Price</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
<span class="text-xl font-bold text-primary">${{ number_format($product->wholesale_price, 2) }}</span>
|
||||
@if($product->price_unit)
|
||||
<span class="text-sm text-base-content/60">/ {{ $product->price_unit }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- SKU --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">SKU</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center font-mono text-sm">{{ $product->sku }}</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Stock Status --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Availability</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->isInStock())
|
||||
<span class="badge badge-success gap-1">
|
||||
<span class="icon-[heroicons--check-circle] size-4"></span>
|
||||
In Stock
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-error gap-1">
|
||||
<span class="icon-[heroicons--x-circle] size-4"></span>
|
||||
Out of Stock
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Category --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Category</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->category)
|
||||
<span class="badge badge-outline">{{ $product->category->name }}</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Strain Type --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Strain Type</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->strain)
|
||||
<span class="badge badge-primary badge-outline">{{ ucfirst($product->strain->type) }}</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- THC % --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">THC Content</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->thc_percentage)
|
||||
<span class="text-lg font-semibold text-primary">{{ $product->thc_percentage }}%</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- CBD % --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">CBD Content</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->cbd_percentage)
|
||||
<span class="text-lg font-semibold text-secondary">{{ $product->cbd_percentage }}%</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Weight --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Net Weight</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->net_weight)
|
||||
{{ $product->net_weight }} {{ $product->weight_unit }}
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Units Per Case --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Units/Case</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->units_per_case)
|
||||
{{ $product->units_per_case }}
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Add to Cart Row --}}
|
||||
<tr>
|
||||
<td class="bg-base-200 sticky left-0"></td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center py-4">
|
||||
@if($product->isInStock())
|
||||
<button @click="addToCart({{ $product->id }})"
|
||||
class="btn btn-primary gap-2">
|
||||
<span class="icon-[heroicons--shopping-cart] size-5"></span>
|
||||
Add to Cart
|
||||
</button>
|
||||
@else
|
||||
<button disabled class="btn btn-disabled">Out of Stock</button>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function compareProducts() {
|
||||
return {
|
||||
async removeProduct(productId) {
|
||||
try {
|
||||
const response = await fetch(`/b/compare/remove/${productId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.dispatchEvent(new CustomEvent('compare-updated'));
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove product:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
try {
|
||||
const response = await fetch('/b/compare/clear', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.dispatchEvent(new CustomEvent('compare-updated'));
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear comparison:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async addToCart(productId) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', 1);
|
||||
|
||||
const business = document.querySelector('[data-business-slug]')?.dataset.businessSlug;
|
||||
if (!business) throw new Error('Business not found');
|
||||
|
||||
const response = await fetch(`/b/${business}/cart/add`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: data.cart_count }
|
||||
}));
|
||||
window.showToast?.('Added to cart', 'success');
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
window.showToast?.(error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -9,7 +9,7 @@
|
||||
<p class="text-base-content/60">Your conversations with brands</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('buyer.crm.inbox.compose') }}" class="btn btn-primary">
|
||||
<a href="{{ route('buyer.crm.inbox.compose', $business) }}" class="btn btn-primary">
|
||||
<x-heroicon-o-pencil-square class="w-4 h-4" />
|
||||
Compose
|
||||
</a>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body p-4">
|
||||
<nav class="space-y-1">
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'all']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'all']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'all' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-inbox class="w-5 h-5" />
|
||||
@@ -30,7 +30,7 @@
|
||||
</span>
|
||||
<span class="badge badge-sm {{ $filter === 'all' ? 'badge-primary-content' : '' }}">{{ $counts['all'] }}</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'unread']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'unread']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'unread' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-envelope class="w-5 h-5" />
|
||||
@@ -40,7 +40,7 @@
|
||||
<span class="badge badge-primary badge-sm">{{ $counts['unread'] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'starred']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'starred']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'starred' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-star class="w-5 h-5" />
|
||||
@@ -48,7 +48,7 @@
|
||||
</span>
|
||||
<span class="badge badge-sm {{ $filter === 'starred' ? 'badge-primary-content' : '' }}">{{ $counts['starred'] }}</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'archived']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'archived']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'archived' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-archive-box class="w-5 h-5" />
|
||||
@@ -67,7 +67,7 @@
|
||||
<!-- Search & Actions Bar -->
|
||||
<div class="border-b border-base-300 p-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<form class="flex-1" action="{{ route('buyer.crm.inbox.index') }}" method="GET">
|
||||
<form class="flex-1" action="{{ route('buyer.crm.inbox.index', $business) }}" method="GET">
|
||||
<input type="hidden" name="filter" value="{{ $filter }}" />
|
||||
<div class="join w-full">
|
||||
<input type="text" name="search" value="{{ $search }}"
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
</form>
|
||||
@if($counts['unread'] > 0)
|
||||
<form action="{{ route('buyer.crm.inbox.mark-all-read') }}" method="POST">
|
||||
<form action="{{ route('buyer.crm.inbox.mark-all-read', $business) }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-ghost btn-sm">
|
||||
<x-heroicon-o-check class="w-4 h-4" />
|
||||
@@ -93,7 +93,7 @@
|
||||
<!-- Threads -->
|
||||
<div class="divide-y divide-base-200">
|
||||
@forelse($threads as $thread)
|
||||
<a href="{{ route('buyer.crm.inbox.show', $thread) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.show', [$business, $thread]) }}"
|
||||
class="flex items-start gap-4 p-4 hover:bg-base-200 transition-colors {{ ($thread->unread_count ?? 0) > 0 ? 'bg-primary/5' : '' }}">
|
||||
<!-- Avatar -->
|
||||
<div class="avatar placeholder flex-shrink-0">
|
||||
@@ -150,7 +150,7 @@
|
||||
@else
|
||||
<p class="text-lg font-medium">No messages yet</p>
|
||||
<p class="text-sm mb-4">Start a conversation with a brand</p>
|
||||
<a href="{{ route('buyer.crm.inbox.compose') }}" class="btn btn-primary">
|
||||
<a href="{{ route('buyer.crm.inbox.compose', $business) }}" class="btn btn-primary">
|
||||
<x-heroicon-o-pencil-square class="w-4 h-4" />
|
||||
Compose Message
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
@extends('layouts.buyer')
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@php
|
||||
$business = $business ?? request()->route('business');
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="flex min-h-screen bg-base-200">
|
||||
@@ -6,19 +10,19 @@
|
||||
<aside class="w-64 bg-base-100 border-r border-base-300 flex-shrink-0 hidden lg:block">
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<h2 class="font-semibold text-lg">CRM Portal</h2>
|
||||
<p class="text-sm text-base-content/60">{{ auth()->user()->business->name ?? 'Your Business' }}</p>
|
||||
<p class="text-sm text-base-content/60">{{ $business->name ?? 'Your Business' }}</p>
|
||||
</div>
|
||||
|
||||
<nav class="p-4 space-y-1">
|
||||
<!-- Dashboard -->
|
||||
<a href="{{ route('buyer.crm.dashboard') }}"
|
||||
<a href="{{ route('buyer.crm.dashboard', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.dashboard') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-home class="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Inbox -->
|
||||
<a href="{{ route('buyer.crm.inbox.index') }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.inbox.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-inbox class="w-5 h-5" />
|
||||
<span>Inbox</span>
|
||||
@@ -28,21 +32,21 @@
|
||||
</a>
|
||||
|
||||
<!-- Orders -->
|
||||
<a href="{{ route('buyer.crm.orders.index') }}"
|
||||
<a href="{{ route('buyer.crm.orders.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.orders.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-shopping-bag class="w-5 h-5" />
|
||||
<span>Orders</span>
|
||||
</a>
|
||||
|
||||
<!-- Quotes -->
|
||||
<a href="{{ route('buyer.crm.quotes.index') }}"
|
||||
<a href="{{ route('buyer.crm.quotes.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.quotes.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-document-text class="w-5 h-5" />
|
||||
<span>Quotes</span>
|
||||
</a>
|
||||
|
||||
<!-- Invoices -->
|
||||
<a href="{{ route('buyer.crm.invoices.index') }}"
|
||||
<a href="{{ route('buyer.crm.invoices.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.invoices.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-receipt-percent class="w-5 h-5" />
|
||||
<span>Invoices</span>
|
||||
@@ -51,14 +55,14 @@
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Brands -->
|
||||
<a href="{{ route('buyer.crm.brands.index') }}"
|
||||
<a href="{{ route('buyer.crm.brands.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.brands.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-building-storefront class="w-5 h-5" />
|
||||
<span>Brands</span>
|
||||
</a>
|
||||
|
||||
<!-- Bookmarks -->
|
||||
<a href="{{ route('buyer.crm.bookmarks.index') }}"
|
||||
<a href="{{ route('buyer.crm.bookmarks.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.bookmarks.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-bookmark class="w-5 h-5" />
|
||||
<span>Bookmarks</span>
|
||||
@@ -67,14 +71,14 @@
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Analytics -->
|
||||
<a href="{{ route('buyer.crm.analytics.index') }}"
|
||||
<a href="{{ route('buyer.crm.analytics.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.analytics.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-chart-bar class="w-5 h-5" />
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
|
||||
<!-- Tasks -->
|
||||
<a href="{{ route('buyer.crm.tasks.index') }}"
|
||||
<a href="{{ route('buyer.crm.tasks.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.tasks.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
|
||||
<span>Tasks</span>
|
||||
@@ -84,7 +88,7 @@
|
||||
</a>
|
||||
|
||||
<!-- Team -->
|
||||
<a href="{{ route('buyer.crm.team.index') }}"
|
||||
<a href="{{ route('buyer.crm.team.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.team.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-user-group class="w-5 h-5" />
|
||||
<span>Team</span>
|
||||
@@ -93,7 +97,7 @@
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Settings -->
|
||||
<a href="{{ route('buyer.crm.settings.index') }}"
|
||||
<a href="{{ route('buyer.crm.settings.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.settings.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-cog-6-tooth class="w-5 h-5" />
|
||||
<span>Settings</span>
|
||||
@@ -142,50 +146,50 @@
|
||||
</div>
|
||||
<nav class="p-4 space-y-1">
|
||||
<!-- Same nav items as desktop -->
|
||||
<a href="{{ route('buyer.crm.dashboard') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.dashboard', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-home class="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.inbox.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-inbox class="w-5 h-5" />
|
||||
<span>Inbox</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.orders.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.orders.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-shopping-bag class="w-5 h-5" />
|
||||
<span>Orders</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.quotes.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.quotes.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-document-text class="w-5 h-5" />
|
||||
<span>Quotes</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.invoices.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.invoices.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-receipt-percent class="w-5 h-5" />
|
||||
<span>Invoices</span>
|
||||
</a>
|
||||
<div class="divider my-2"></div>
|
||||
<a href="{{ route('buyer.crm.brands.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.brands.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-building-storefront class="w-5 h-5" />
|
||||
<span>Brands</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.bookmarks.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.bookmarks.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-bookmark class="w-5 h-5" />
|
||||
<span>Bookmarks</span>
|
||||
</a>
|
||||
<div class="divider my-2"></div>
|
||||
<a href="{{ route('buyer.crm.analytics.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.analytics.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-chart-bar class="w-5 h-5" />
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.tasks.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.tasks.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.team.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.team.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-user-group class="w-5 h-5" />
|
||||
<span>Team</span>
|
||||
</a>
|
||||
<div class="divider my-2"></div>
|
||||
<a href="{{ route('buyer.crm.settings.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.settings.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-cog-6-tooth class="w-5 h-5" />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
|
||||
@@ -7,90 +7,158 @@
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid py-6">
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="breadcrumbs text-sm mb-6">
|
||||
<div class="min-h-screen" x-data="brandPage()">
|
||||
{{-- Hero Banner Section --}}
|
||||
<div class="relative -mx-6 -mt-6 mb-6">
|
||||
@if($brand->banner_path)
|
||||
<div class="h-48 md:h-64 lg:h-80 bg-base-200 overflow-hidden">
|
||||
<img src="{{ route('image.brand-banner', [$brand->hashid, 1344]) }}"
|
||||
alt="{{ $brand->name }} banner"
|
||||
class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent"></div>
|
||||
</div>
|
||||
@else
|
||||
<div class="h-32 md:h-48 bg-gradient-to-r from-primary via-primary to-primary-focus">
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-0 right-0 w-1/2 h-full bg-white skew-x-12 transform origin-top-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Brand Info Overlay --}}
|
||||
<div class="absolute bottom-0 left-0 right-0 px-6 pb-6">
|
||||
<div class="flex items-end gap-4">
|
||||
{{-- Logo --}}
|
||||
<div class="avatar flex-shrink-0 -mb-12 relative z-10">
|
||||
<div class="w-24 h-24 md:w-32 md:h-32 rounded-xl bg-base-100 shadow-xl border-4 border-base-100 overflow-hidden">
|
||||
@if($brand->logo_path)
|
||||
<img src="{{ route('image.brand-logo', [$brand->hashid, 256]) }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="w-full h-full object-contain p-2">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full w-full bg-base-200">
|
||||
<span class="text-4xl font-bold text-base-content/30">{{ substr($brand->name, 0, 1) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Brand Name & Stats --}}
|
||||
<div class="flex-1 pb-2 {{ $brand->banner_path ? 'text-white' : 'text-white' }}">
|
||||
<h1 class="text-2xl md:text-3xl font-bold drop-shadow-lg">{{ $brand->name }}</h1>
|
||||
@if($brand->tagline)
|
||||
<p class="text-sm md:text-base opacity-90 drop-shadow">{{ $brand->tagline }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Brand Details Card (below hero) --}}
|
||||
<div class="ml-0 md:ml-36 mb-6 pt-8 md:pt-0">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
{{-- Stats --}}
|
||||
<div class="flex items-center gap-4 text-sm text-base-content/70">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="icon-[heroicons--cube] size-4"></span>
|
||||
{{ $products->total() }} products
|
||||
</span>
|
||||
@if($brand->business)
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="icon-[heroicons--building-storefront] size-4"></span>
|
||||
{{ $brand->business->name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Social Links --}}
|
||||
@if($brand->website_url || $brand->instagram_handle)
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
@if($brand->website_url)
|
||||
<a href="{{ $brand->website_url }}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-xs gap-1">
|
||||
<span class="icon-[heroicons--globe-alt] size-4"></span>
|
||||
Website
|
||||
</a>
|
||||
@endif
|
||||
@if($brand->instagram_handle)
|
||||
<a href="https://instagram.com/{{ $brand->instagram_handle }}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-xs gap-1">
|
||||
<span class="icon-[lucide--instagram] size-4"></span>
|
||||
@{{ $brand->instagram_handle }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex items-center gap-2">
|
||||
@if($brand->sales_email)
|
||||
<a href="mailto:{{ $brand->sales_email }}" class="btn btn-outline btn-sm gap-2">
|
||||
<span class="icon-[heroicons--envelope] size-4"></span>
|
||||
Contact Sales
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Brand Description (collapsible) --}}
|
||||
@if($brand->description || $brand->long_description)
|
||||
<div class="mt-4" x-data="{ expanded: false }">
|
||||
<p class="text-base-content/80" :class="{ 'line-clamp-2': !expanded }">
|
||||
{{ $brand->description ?: $brand->long_description }}
|
||||
</p>
|
||||
@if(strlen($brand->description ?: $brand->long_description) > 200)
|
||||
<button @click="expanded = !expanded" class="text-primary text-sm mt-1 hover:underline">
|
||||
<span x-text="expanded ? 'Show less' : 'Read more'"></span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Breadcrumbs --}}
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<li><a href="{{ route('buyer.dashboard') }}">Dashboard</a></li>
|
||||
<li><a href="{{ route('buyer.browse') }}">Shop</a></li>
|
||||
<li><a href="{{ route('buyer.brands.index') }}">Brands</a></li>
|
||||
<li class="opacity-80">{{ $brand->name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Brand Header Card -->
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card-body">
|
||||
@include('brands._storefront', ['brand' => $brand])
|
||||
{{-- Banner Ad: Brand Page --}}
|
||||
<x-banner-ad zone="brand_page_banner" />
|
||||
|
||||
{{-- Featured Products --}}
|
||||
@if($featuredProducts->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-5 text-warning"></span>
|
||||
Featured Products
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory scrollbar-hide -mx-2 px-2">
|
||||
@foreach($featuredProducts as $product)
|
||||
<x-marketplace.product-card :product="$product" variant="compact" :showBrand="false" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Featured Products Carousel -->
|
||||
@if($featuredProducts->count() > 0)
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-4">
|
||||
<span class="icon-[heroicons--star] size-5 text-warning"></span>
|
||||
Featured Products
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@foreach($featuredProducts as $product)
|
||||
<div class="card bg-base-200 hover:bg-base-300 transition-colors">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex gap-4">
|
||||
<!-- Product Image -->
|
||||
<div class="avatar flex-shrink-0">
|
||||
<div class="w-20 h-20 rounded-lg bg-base-100">
|
||||
@if($product->image_path)
|
||||
<img src="{{ route('image.product', [$product->hashid, 160]) }}" alt="{{ $product->name }}" class="object-cover">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="icon-[heroicons--cube] size-8 text-base-content/30"></span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->hashid]) }}" class="font-semibold text-sm hover:text-primary line-clamp-2">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
|
||||
@if($product->strain)
|
||||
<div class="flex gap-1 mt-1">
|
||||
<span class="badge badge-xs badge-outline">{{ ucfirst($product->strain->type) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="text-lg font-bold text-primary mt-2">
|
||||
${{ number_format($product->wholesale_price, 2) }}
|
||||
@if($product->price_unit)
|
||||
<span class="text-xs text-base-content/60 font-normal">/ {{ $product->price_unit }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Menu Filter Tabs -->
|
||||
{{-- Menu/Category Tabs --}}
|
||||
@if(isset($menus) && $menus->count() > 0)
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{-- All Products tab --}}
|
||||
<div class="mb-6 overflow-x-auto">
|
||||
<div class="flex items-center gap-2 min-w-max">
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
|
||||
class="btn btn-sm {{ !isset($selectedMenu) ? 'btn-primary' : 'btn-ghost' }} gap-2">
|
||||
<span class="icon-[heroicons--squares-2x2] size-4"></span>
|
||||
All Products
|
||||
<span class="badge badge-xs {{ !isset($selectedMenu) ? 'badge-primary-content' : 'badge-ghost' }}">{{ $products->total() }}</span>
|
||||
</a>
|
||||
|
||||
{{-- System menus first --}}
|
||||
@foreach($menus->where('is_system', true) as $menu)
|
||||
@php
|
||||
$menuIcon = match($menu->slug) {
|
||||
@@ -112,15 +180,11 @@
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- User menus --}}
|
||||
@foreach($menus->where('is_system', false) as $menu)
|
||||
@php
|
||||
$isSelected = isset($selectedMenu) && $selectedMenu->id === $menu->id;
|
||||
@endphp
|
||||
@php $isSelected = isset($selectedMenu) && $selectedMenu->id === $menu->id; @endphp
|
||||
@if($menu->display_count > 0 || $isSelected)
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}?menu={{ $menu->slug }}"
|
||||
class="btn btn-sm {{ $isSelected ? 'btn-primary' : 'btn-ghost' }} gap-2">
|
||||
<span class="icon-[heroicons--rectangle-stack] size-4"></span>
|
||||
{{ $menu->name }}
|
||||
<span class="badge badge-xs {{ $isSelected ? 'badge-primary-content' : 'badge-ghost' }}">{{ $menu->display_count }}</span>
|
||||
</a>
|
||||
@@ -130,167 +194,75 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- All Products List -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-4">
|
||||
{{-- Products Section --}}
|
||||
<div class="mb-8">
|
||||
{{-- Section Header --}}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||
<h2 class="text-xl font-bold">
|
||||
@if(isset($selectedMenu))
|
||||
<span class="flex items-center gap-2">
|
||||
@php
|
||||
$titleIcon = match($selectedMenu->slug) {
|
||||
'available-now' => 'icon-[heroicons--check-badge] text-success',
|
||||
'promotions' => 'icon-[heroicons--tag] text-secondary',
|
||||
'daily-deals' => 'icon-[heroicons--fire] text-warning',
|
||||
'best-sellers' => 'icon-[heroicons--trophy] text-info',
|
||||
default => 'icon-[heroicons--rectangle-stack]',
|
||||
};
|
||||
@endphp
|
||||
<span class="{{ $titleIcon }} size-5"></span>
|
||||
{{ $selectedMenu->name }}
|
||||
</span>
|
||||
{{ $selectedMenu->name }}
|
||||
@else
|
||||
All Products
|
||||
@endif
|
||||
</h2>
|
||||
|
||||
@if($products->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Type</th>
|
||||
<th>THC/CBD</th>
|
||||
<th class="text-right">Price</th>
|
||||
<th class="text-center">Stock</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($products as $product)
|
||||
<tr class="hover">
|
||||
<!-- Product Info -->
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="mask mask-squircle w-12 h-12">
|
||||
@if($product->image_path)
|
||||
<img src="{{ route('image.product', [$product->hashid, 80]) }}" alt="{{ $product->name }}">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full w-full bg-base-200">
|
||||
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->hashid]) }}" class="hover:text-primary">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60">SKU: {{ $product->sku }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Strain Type -->
|
||||
<td>
|
||||
@if($product->strain)
|
||||
<span class="badge badge-sm badge-outline">{{ ucfirst($product->strain->type) }}</span>
|
||||
@else
|
||||
<span class="text-base-content/40">—</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
<!-- THC/CBD -->
|
||||
<td>
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
@if($product->thc_percentage)
|
||||
<span class="badge badge-xs badge-primary">THC {{ $product->thc_percentage }}%</span>
|
||||
@endif
|
||||
@if($product->cbd_percentage)
|
||||
<span class="badge badge-xs badge-success">CBD {{ $product->cbd_percentage }}%</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Price -->
|
||||
<td class="text-right">
|
||||
<div class="font-semibold text-primary">${{ number_format($product->wholesale_price, 2) }}</div>
|
||||
@if($product->price_unit)
|
||||
<div class="text-xs text-base-content/60">per {{ $product->price_unit }}</div>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
<!-- Stock -->
|
||||
<td class="text-center">
|
||||
@if($product->available_quantity > 0)
|
||||
<span class="badge badge-success badge-sm whitespace-nowrap">{{ $product->available_quantity }} units</span>
|
||||
@else
|
||||
<span class="badge badge-error badge-sm whitespace-nowrap">Out of Stock</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-right">
|
||||
<div class="flex gap-2 justify-end items-center flex-nowrap">
|
||||
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->hashid]) }}" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
</a>
|
||||
|
||||
@if($product->isInStock())
|
||||
<form action="{{ route('buyer.business.cart.add', auth()->user()->businesses->first()) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="product_id" value="{{ $product->id }}">
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<button disabled class="btn btn-disabled btn-sm whitespace-nowrap">
|
||||
Out of Stock
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
{{-- View Toggle --}}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-base-content/70">{{ $products->total() }} products</span>
|
||||
<div class="btn-group">
|
||||
<button @click="viewMode = 'grid'"
|
||||
class="btn btn-sm"
|
||||
:class="viewMode === 'grid' ? 'btn-active' : ''">
|
||||
<span class="icon-[heroicons--squares-2x2] size-4"></span>
|
||||
</button>
|
||||
<button @click="viewMode = 'list'"
|
||||
class="btn btn-sm"
|
||||
:class="viewMode === 'list' ? 'btn-active' : ''">
|
||||
<span class="icon-[heroicons--bars-3] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if($products->hasPages())
|
||||
<div class="flex justify-center mt-6">
|
||||
{{ $products->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-12">
|
||||
{{-- Products Grid/List --}}
|
||||
@if($products->count() > 0)
|
||||
{{-- Grid View --}}
|
||||
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
@foreach($products as $product)
|
||||
<x-marketplace.product-card :product="$product" variant="grid" :showBrand="false" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- List View --}}
|
||||
<div x-show="viewMode === 'list'" x-cloak class="space-y-4">
|
||||
@foreach($products as $product)
|
||||
<x-marketplace.product-card :product="$product" variant="list" :showBrand="false" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($products->hasPages())
|
||||
<div class="flex justify-center mt-6">
|
||||
{{ $products->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
{{-- Empty State --}}
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body text-center py-16">
|
||||
@if(isset($selectedMenu))
|
||||
@if($selectedMenu->slug === 'promotions')
|
||||
<span class="icon-[heroicons--tag] size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-lg font-semibold text-base-content/70 mb-2">No Active Promotions</h3>
|
||||
<p class="text-base-content/50">There are no products with active promotions right now. Check back soon!</p>
|
||||
@elseif($selectedMenu->slug === 'available-now')
|
||||
<span class="icon-[heroicons--check-badge] size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-lg font-semibold text-base-content/70 mb-2">No Products In Stock</h3>
|
||||
<p class="text-base-content/50">Products in this menu are currently out of stock.</p>
|
||||
@elseif($selectedMenu->slug === 'best-sellers')
|
||||
<span class="icon-[heroicons--trophy] size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-lg font-semibold text-base-content/70 mb-2">No Best Sellers Yet</h3>
|
||||
<p class="text-base-content/50">Best selling products will appear here based on order history.</p>
|
||||
@else
|
||||
<span class="icon-[heroicons--rectangle-stack] size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-lg font-semibold text-base-content/70 mb-2">No Products in This Menu</h3>
|
||||
<p class="text-base-content/50">{{ $selectedMenu->description ?: 'This menu is currently empty.' }}</p>
|
||||
@endif
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}" class="btn btn-outline btn-sm mt-4">
|
||||
<span class="icon-[heroicons--arrow-left] size-4"></span>
|
||||
@php
|
||||
$emptyIcon = match($selectedMenu->slug) {
|
||||
'promotions' => 'icon-[heroicons--tag]',
|
||||
'available-now' => 'icon-[heroicons--check-badge]',
|
||||
'best-sellers' => 'icon-[heroicons--trophy]',
|
||||
default => 'icon-[heroicons--rectangle-stack]',
|
||||
};
|
||||
@endphp
|
||||
<span class="{{ $emptyIcon }} size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-lg font-semibold text-base-content/70 mb-2">No Products in {{ $selectedMenu->name }}</h3>
|
||||
<p class="text-base-content/50 mb-4">{{ $selectedMenu->description ?: 'This collection is currently empty.' }}</p>
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}" class="btn btn-primary btn-sm">
|
||||
View All Products
|
||||
</a>
|
||||
@else
|
||||
@@ -299,65 +271,65 @@
|
||||
<p class="text-base-content/50">This brand doesn't have any products listed yet.</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Handle all Add to Cart form submissions
|
||||
document.querySelectorAll('form[action*="cart/add"]').forEach(form => {
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
function brandPage() {
|
||||
return {
|
||||
viewMode: localStorage.getItem('brandPageViewMode') || 'grid',
|
||||
|
||||
const button = this.querySelector('button[type="submit"]');
|
||||
const originalText = button.innerHTML;
|
||||
init() {
|
||||
this.$watch('viewMode', (value) => {
|
||||
localStorage.setItem('brandPageViewMode', value);
|
||||
});
|
||||
|
||||
// Disable button and show loading state
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||
// Listen for add-to-cart events
|
||||
this.$el.addEventListener('add-to-cart', async (e) => {
|
||||
const productId = e.detail.productId;
|
||||
await this.addToCart(productId);
|
||||
});
|
||||
|
||||
this.$el.addEventListener('quick-add-to-cart', async (e) => {
|
||||
const productId = e.detail.productId;
|
||||
await this.addToCart(productId);
|
||||
});
|
||||
},
|
||||
|
||||
async addToCart(productId) {
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const response = await fetch(this.action, {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', 1);
|
||||
|
||||
const response = await fetch('{{ route("buyer.business.cart.add", auth()->user()->businesses->first()->slug) }}', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update cart count
|
||||
window.dispatchEvent(new CustomEvent('cart-updated'));
|
||||
|
||||
// Show success feedback
|
||||
button.innerHTML = '<span class="icon-[heroicons--check] size-4"></span> Added!';
|
||||
button.classList.add('btn-success');
|
||||
|
||||
// Reset button after 2 seconds
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('btn-success');
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
window.showToast('Added to cart', 'success');
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to add to cart');
|
||||
window.showToast(data.message || 'Failed to add to cart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to add product to cart. Please try again.');
|
||||
button.innerHTML = originalText;
|
||||
button.disabled = false;
|
||||
window.showToast('Failed to add product to cart', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid py-6">
|
||||
{{-- Banner Ad: Deals Page Hero --}}
|
||||
<x-banner-ad zone="deals_page_hero" />
|
||||
|
||||
{{-- Page Header --}}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Today's Deals</h1>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
@include('partials.pwa')
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<script>
|
||||
|
||||
23
resources/views/components/banner-ad-leaderboard.blade.php
Normal file
23
resources/views/components/banner-ad-leaderboard.blade.php
Normal file
@@ -0,0 +1,23 @@
|
||||
{{-- Leaderboard Banner (728x90) - centered, subtle background --}}
|
||||
@props(['ad'])
|
||||
|
||||
@if($ad)
|
||||
<div class="flex justify-center py-4 bg-base-200/30 rounded-lg my-4">
|
||||
<a
|
||||
href="{{ $ad->getClickUrl() }}"
|
||||
target="{{ str_starts_with($ad->cta_url, url('/')) ? '_self' : '_blank' }}"
|
||||
rel="noopener"
|
||||
class="block relative overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<img
|
||||
src="{{ $ad->getImageUrl(728) }}"
|
||||
alt="{{ $ad->image_alt ?? $ad->name }}"
|
||||
class="w-full max-w-[728px] h-auto object-cover"
|
||||
loading="lazy"
|
||||
width="728"
|
||||
height="90"
|
||||
>
|
||||
<span class="absolute bottom-1 right-1 text-[10px] text-white/70 bg-black/30 px-1 rounded">Ad</span>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
23
resources/views/components/banner-ad-sidebar.blade.php
Normal file
23
resources/views/components/banner-ad-sidebar.blade.php
Normal file
@@ -0,0 +1,23 @@
|
||||
{{-- Sidebar Banner (300x250) - fits in filter sidebar --}}
|
||||
@props(['ad'])
|
||||
|
||||
@if($ad)
|
||||
<div class="card bg-base-100 shadow-lg overflow-hidden my-4">
|
||||
<a
|
||||
href="{{ $ad->getClickUrl() }}"
|
||||
target="{{ str_starts_with($ad->cta_url, url('/')) ? '_self' : '_blank' }}"
|
||||
rel="noopener"
|
||||
class="block hover:opacity-95 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="{{ $ad->getImageUrl(300) }}"
|
||||
alt="{{ $ad->image_alt ?? $ad->name }}"
|
||||
class="w-full h-auto"
|
||||
loading="lazy"
|
||||
width="300"
|
||||
height="250"
|
||||
>
|
||||
</a>
|
||||
<div class="text-center py-1 text-xs text-base-content/50">Sponsored</div>
|
||||
</div>
|
||||
@endif
|
||||
53
resources/views/components/banner-ad.blade.php
Normal file
53
resources/views/components/banner-ad.blade.php
Normal file
@@ -0,0 +1,53 @@
|
||||
@props(['ad', 'zone', 'dimensions'])
|
||||
|
||||
@if($ad)
|
||||
<div
|
||||
class="banner-ad banner-ad--{{ $zone->value }} my-4"
|
||||
data-ad-id="{{ $ad->id }}"
|
||||
data-zone="{{ $zone->value }}"
|
||||
>
|
||||
<a
|
||||
href="{{ $ad->getClickUrl() }}"
|
||||
target="{{ str_starts_with($ad->cta_url, url('/')) ? '_self' : '_blank' }}"
|
||||
rel="noopener"
|
||||
class="block relative overflow-hidden rounded-lg group"
|
||||
>
|
||||
{{-- Banner Image --}}
|
||||
<img
|
||||
src="{{ $ad->getImageUrl($dimensions['width']) }}"
|
||||
alt="{{ $ad->image_alt ?? $ad->name }}"
|
||||
class="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
width="{{ $dimensions['width'] }}"
|
||||
height="{{ $dimensions['height'] }}"
|
||||
>
|
||||
|
||||
{{-- Overlay Content (if headline/description/cta provided) --}}
|
||||
@if($ad->headline || $ad->description || $ad->cta_text)
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-black/60 via-black/30 to-transparent flex items-center">
|
||||
<div class="p-4 md:p-6 max-w-lg text-white">
|
||||
@if($ad->headline)
|
||||
<h3 class="text-lg md:text-2xl font-bold mb-1 md:mb-2 drop-shadow-lg">{{ $ad->headline }}</h3>
|
||||
@endif
|
||||
|
||||
@if($ad->description)
|
||||
<p class="text-xs md:text-sm opacity-90 mb-2 md:mb-4 drop-shadow line-clamp-2">{{ $ad->description }}</p>
|
||||
@endif
|
||||
|
||||
@if($ad->cta_text)
|
||||
<span class="btn btn-primary btn-sm">
|
||||
{{ $ad->cta_text }}
|
||||
<span class="icon-[heroicons--arrow-right] size-4 ml-1"></span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Sponsored badge --}}
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="badge badge-xs bg-black/50 text-white border-0 text-[10px]">Sponsored</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@@ -86,9 +86,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="menu-item" href="#">
|
||||
<span class="icon-[heroicons--megaphone] size-4"></span>
|
||||
<span class="grow">Promotion</span>
|
||||
<a class="menu-item {{ request()->routeIs('buyer.deals') ? 'active' : '' }}" href="{{ route('buyer.deals') }}">
|
||||
<span class="icon-[heroicons--tag] size-4"></span>
|
||||
<span class="grow">Deals</span>
|
||||
</a>
|
||||
|
||||
<a class="menu-item {{ request()->routeIs('buyer.browse*') ? 'active' : '' }}" href="{{ route('buyer.browse') }}">
|
||||
@@ -96,6 +96,11 @@
|
||||
<span class="grow">Shop</span>
|
||||
</a>
|
||||
|
||||
<a class="menu-item {{ request()->routeIs('buyer.business.buy-again') ? 'active' : '' }}" href="{{ route('buyer.business.buy-again', auth()->user()->businesses->first()->slug) }}">
|
||||
<span class="icon-[heroicons--arrow-path-rounded-square] size-4"></span>
|
||||
<span class="grow">Buy It Again</span>
|
||||
</a>
|
||||
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
|
||||
120
resources/views/components/buyer-topbar-account.blade.php
Normal file
120
resources/views/components/buyer-topbar-account.blade.php
Normal file
@@ -0,0 +1,120 @@
|
||||
{{--
|
||||
Buyer Topbar Account Dropdown
|
||||
|
||||
Positioned in the top-right navbar, next to alerts.
|
||||
Settings is accessible ONLY from this dropdown (not in sidebar).
|
||||
|
||||
Structure:
|
||||
- Profile
|
||||
- Password
|
||||
- Settings (Owner/Admin only)
|
||||
- Sign Out
|
||||
--}}
|
||||
|
||||
@php
|
||||
$business = auth()->user()?->businesses->first();
|
||||
$user = auth()->user();
|
||||
$isOwner = $business && $business->owner_user_id === $user->id;
|
||||
$isSuperAdmin = $user->user_type === 'admin';
|
||||
$canManageSettings = $isOwner || $isSuperAdmin;
|
||||
@endphp
|
||||
|
||||
<div x-data="{ accountOpen: false }" class="relative">
|
||||
{{-- Avatar Button (always visible) --}}
|
||||
<button
|
||||
@click="accountOpen = !accountOpen"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
aria-label="Account Menu">
|
||||
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-primary-content text-xs font-semibold">
|
||||
{{ strtoupper(substr($user->first_name ?? 'U', 0, 1)) }}{{ strtoupper(substr($user->last_name ?? 'S', 0, 1)) }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{{-- Dropdown Menu (opens downward from topbar) --}}
|
||||
<div x-show="accountOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.away="accountOpen = false"
|
||||
class="absolute right-0 top-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-200 z-50 w-56">
|
||||
|
||||
{{-- User Info Header --}}
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<div class="font-medium text-sm truncate">
|
||||
{{ trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')) ?: 'User' }}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{{ $user->email }}</div>
|
||||
@if($business)
|
||||
<div class="text-xs text-base-content/50 truncate mt-0.5">{{ $business->name }}</div>
|
||||
@endif
|
||||
<div class="mt-1">
|
||||
@if($isOwner)
|
||||
<span class="badge badge-primary badge-xs">Owner</span>
|
||||
@elseif($isSuperAdmin)
|
||||
<span class="badge badge-error badge-xs">Super Admin</span>
|
||||
@else
|
||||
<span class="badge badge-ghost badge-xs">Team Member</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<ul class="space-y-0.5">
|
||||
{{-- Profile --}}
|
||||
<li>
|
||||
<a href="{{ $business ? route('buyer.crm.settings.account', $business->slug) : route('buyer.profile') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--user] size-4 text-base-content/50"></span>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{-- Orders --}}
|
||||
<li>
|
||||
<a href="{{ $business ? route('buyer.crm.orders.index', $business->slug) : '#' }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--package] size-4 text-base-content/50"></span>
|
||||
<span>My Orders</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{-- Favorites --}}
|
||||
<li>
|
||||
<a href="{{ $business ? route('buyer.crm.bookmarks.index', $business->slug) : '#' }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--heart] size-4 text-base-content/50"></span>
|
||||
<span>Favorites</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{-- Admin Console Section (Owner/Admin Only) --}}
|
||||
@if($canManageSettings && $business)
|
||||
<li class="pt-2 mt-2 border-t border-base-200">
|
||||
<span class="px-3 text-xs font-semibold text-base-content/40 uppercase tracking-wider">Admin Console</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('buyer.crm.settings.index', $business->slug) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--settings] size-4 text-base-content/50"></span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
|
||||
{{-- Sign Out --}}
|
||||
<div class="border-t border-base-200 my-2"></div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors w-full text-error">
|
||||
<span class="icon-[lucide--log-out] size-4"></span>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,87 +0,0 @@
|
||||
@props(['business', 'alerts' => null, 'limit' => 5])
|
||||
|
||||
@php
|
||||
// Get inventory alerts if not passed
|
||||
if ($alerts === null) {
|
||||
$alerts = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereNull('resolved_at')
|
||||
->with('product:id,name,sku')
|
||||
->orderByRaw("CASE severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END")
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
$totalAlerts = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereNull('resolved_at')
|
||||
->count();
|
||||
|
||||
$criticalCount = \App\Models\InventoryAlert::where('business_id', $business->id)
|
||||
->whereNull('resolved_at')
|
||||
->where('severity', 'critical')
|
||||
->count();
|
||||
@endphp
|
||||
|
||||
<x-dashboard.rail-card
|
||||
title="Inventory Alerts"
|
||||
icon="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
:badge="$totalAlerts > 0 ? $totalAlerts : null"
|
||||
:badgeClass="$criticalCount > 0 ? 'badge-error' : 'badge-warning'"
|
||||
:href="$totalAlerts > 0 ? route('seller.business.inventory.index', $business->slug) : null"
|
||||
hrefLabel="View all"
|
||||
>
|
||||
@if($alerts->isEmpty())
|
||||
<div class="flex flex-col items-center py-6 text-center">
|
||||
<div class="w-10 h-10 rounded-full bg-success/15 flex items-center justify-center mb-2">
|
||||
<span class="icon-[heroicons--check] size-5 text-success"></span>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">All stock levels healthy</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach($alerts as $alert)
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-base-200/50 transition-colors">
|
||||
<span class="flex-shrink-0 w-2 h-2 rounded-full
|
||||
{{ match($alert->severity) {
|
||||
'critical' => 'bg-error',
|
||||
'high' => 'bg-warning',
|
||||
'medium' => 'bg-info',
|
||||
default => 'bg-base-content/30'
|
||||
} }}"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content truncate">
|
||||
{{ $alert->product?->name ?? 'Unknown Product' }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
<span class="badge badge-xs
|
||||
{{ match($alert->alert_type) {
|
||||
'out_of_stock' => 'badge-error',
|
||||
'low_stock' => 'badge-warning',
|
||||
'reorder_point' => 'badge-info',
|
||||
default => 'badge-ghost'
|
||||
} }}">
|
||||
{{ str_replace('_', ' ', ucfirst($alert->alert_type)) }}
|
||||
</span>
|
||||
@if($alert->current_quantity !== null)
|
||||
<span class="ml-1">{{ $alert->current_quantity }} units</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.products.show', [$business->slug, $alert->product?->hashid ?? '']) }}"
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="View product">
|
||||
<span class="icon-[heroicons--arrow-right] size-3.5"></span>
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($totalAlerts > $limit)
|
||||
<div class="pt-2 border-t border-base-200">
|
||||
<a href="{{ route('seller.business.inventory.index', $business->slug) }}"
|
||||
class="btn btn-sm btn-ghost w-full">
|
||||
View {{ $totalAlerts - $limit }} more alerts
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</x-dashboard.rail-card>
|
||||
@@ -1,107 +0,0 @@
|
||||
@props(['business', 'metrics' => null])
|
||||
|
||||
@php
|
||||
use App\Models\Crm\CrmDeal;
|
||||
|
||||
// Compute metrics if not passed
|
||||
if ($metrics === null) {
|
||||
$metrics = [
|
||||
'avg_days_to_close' => 0,
|
||||
'deals_closed_30d' => 0,
|
||||
'win_rate' => 0,
|
||||
'total_pipeline_value' => 0,
|
||||
];
|
||||
|
||||
// Get closed won deals in last 90 days for velocity calculation
|
||||
$closedDeals = CrmDeal::forBusiness($business->id)
|
||||
->whereIn('stage', ['won', 'closed_won'])
|
||||
->whereNotNull('actual_close_date')
|
||||
->where('actual_close_date', '>=', now()->subDays(90))
|
||||
->get();
|
||||
|
||||
if ($closedDeals->isNotEmpty()) {
|
||||
$metrics['avg_days_to_close'] = round($closedDeals->avg(function ($deal) {
|
||||
return $deal->created_at->diffInDays($deal->actual_close_date);
|
||||
}));
|
||||
|
||||
$metrics['deals_closed_30d'] = $closedDeals
|
||||
->where('actual_close_date', '>=', now()->subDays(30))
|
||||
->count();
|
||||
}
|
||||
|
||||
// Calculate win rate (won vs lost in last 90 days)
|
||||
$totalClosed = CrmDeal::forBusiness($business->id)
|
||||
->whereIn('stage', ['won', 'closed_won', 'lost', 'closed_lost'])
|
||||
->where('actual_close_date', '>=', now()->subDays(90))
|
||||
->count();
|
||||
|
||||
$wonCount = CrmDeal::forBusiness($business->id)
|
||||
->whereIn('stage', ['won', 'closed_won'])
|
||||
->where('actual_close_date', '>=', now()->subDays(90))
|
||||
->count();
|
||||
|
||||
$metrics['win_rate'] = $totalClosed > 0 ? round(($wonCount / $totalClosed) * 100) : 0;
|
||||
|
||||
// Total pipeline value (open deals)
|
||||
$metrics['total_pipeline_value'] = CrmDeal::forBusiness($business->id)
|
||||
->open()
|
||||
->sum('value') / 100;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-dashboard.rail-card
|
||||
title="Pipeline Velocity"
|
||||
icon="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
:href="route('seller.business.crm.deals.index', $business->slug)"
|
||||
hrefLabel="View deals"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{{-- Avg Days to Close --}}
|
||||
<div class="bg-base-200/30 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-base-content tabular-nums">
|
||||
{{ $metrics['avg_days_to_close'] }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">Avg days to close</p>
|
||||
</div>
|
||||
|
||||
{{-- Win Rate --}}
|
||||
<div class="bg-base-200/30 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold tabular-nums {{ $metrics['win_rate'] >= 50 ? 'text-success' : ($metrics['win_rate'] >= 30 ? 'text-warning' : 'text-error') }}">
|
||||
{{ $metrics['win_rate'] }}%
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">Win rate (90d)</p>
|
||||
</div>
|
||||
|
||||
{{-- Deals Closed 30d --}}
|
||||
<div class="bg-base-200/30 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-base-content tabular-nums">
|
||||
{{ $metrics['deals_closed_30d'] }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">Closed this month</p>
|
||||
</div>
|
||||
|
||||
{{-- Pipeline Value --}}
|
||||
<div class="bg-base-200/30 rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-primary tabular-nums">
|
||||
${{ number_format($metrics['total_pipeline_value'], 0) }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">Pipeline value</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick insight --}}
|
||||
@if($metrics['avg_days_to_close'] > 0 && $metrics['win_rate'] > 0)
|
||||
<div class="mt-3 p-2 rounded-lg bg-info/10 border border-info/20">
|
||||
<p class="text-xs text-info">
|
||||
<span class="icon-[heroicons--light-bulb] size-3 inline-block mr-1"></span>
|
||||
@if($metrics['avg_days_to_close'] <= 14)
|
||||
Fast sales cycle! Consider increasing pipeline volume.
|
||||
@elseif($metrics['avg_days_to_close'] <= 30)
|
||||
Healthy velocity. Focus on maintaining win rate.
|
||||
@else
|
||||
Longer cycles detected. Review stuck deals.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</x-dashboard.rail-card>
|
||||
@@ -53,7 +53,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $icon }}" />
|
||||
</svg>
|
||||
@endif
|
||||
<span class="text-xs font-medium text-base-content/60 uppercase tracking-wide leading-tight">{{ $label }}</span>
|
||||
<span class="text-xs font-medium text-base-content/60 uppercase tracking-wide truncate">{{ $label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="{{ $size === 'compact' ? 'text-xl' : 'text-2xl' }} font-bold text-base-content tabular-nums">
|
||||
|
||||
290
resources/views/components/marketplace-search.blade.php
Normal file
290
resources/views/components/marketplace-search.blade.php
Normal file
@@ -0,0 +1,290 @@
|
||||
{{-- Marketplace Search Autocomplete Component --}}
|
||||
<div class="relative flex-1 max-w-xl"
|
||||
x-data="marketplaceSearch()"
|
||||
@click.away="close()"
|
||||
@keydown.escape.window="close()">
|
||||
|
||||
{{-- Search Input --}}
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
@focus="onFocus()"
|
||||
@keydown.down.prevent="moveDown()"
|
||||
@keydown.up.prevent="moveUp()"
|
||||
@keydown.enter.prevent="selectCurrent()"
|
||||
placeholder="Search products, brands..."
|
||||
class="input input-bordered w-full pl-10 pr-10 h-10"
|
||||
autocomplete="off"
|
||||
aria-label="Search products and brands"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="isOpen">
|
||||
|
||||
{{-- Clear button --}}
|
||||
<button
|
||||
x-show="query.length > 0"
|
||||
x-cloak
|
||||
@click="clear()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/50 hover:text-base-content">
|
||||
<span class="icon-[heroicons--x-mark] size-5"></span>
|
||||
</button>
|
||||
|
||||
{{-- Loading indicator --}}
|
||||
<span
|
||||
x-show="loading"
|
||||
x-cloak
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<span class="loading loading-spinner loading-sm text-primary"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Dropdown Results --}}
|
||||
<div
|
||||
x-show="isOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-box shadow-xl border border-base-200 z-50 max-h-[70vh] overflow-y-auto"
|
||||
role="listbox">
|
||||
|
||||
{{-- No Query: Show Suggestions --}}
|
||||
<template x-if="!query && suggestions">
|
||||
<div class="p-4">
|
||||
{{-- Popular Searches --}}
|
||||
<template x-if="suggestions.terms && suggestions.terms.length > 0">
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider mb-2">Popular Searches</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="term in suggestions.terms" :key="term">
|
||||
<button
|
||||
@click="searchTerm(term)"
|
||||
class="badge badge-outline badge-lg hover:badge-primary cursor-pointer transition-colors"
|
||||
x-text="term">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Trending Products --}}
|
||||
<template x-if="suggestions.trending && suggestions.trending.length > 0">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider mb-2">Trending Products</h4>
|
||||
<div class="space-y-2">
|
||||
<template x-for="(product, index) in suggestions.trending" :key="product.id">
|
||||
<a :href="product.url"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-200 transition-colors"
|
||||
@click="close()">
|
||||
<div class="w-10 h-10 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden">
|
||||
<template x-if="product.image_url">
|
||||
<img :src="product.image_url" :alt="product.name" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!product.image_url">
|
||||
<span class="icon-[heroicons--cube] size-5 text-base-content/30 m-auto"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" x-text="product.name"></p>
|
||||
<p class="text-xs text-base-content/60" x-text="product.brand_name"></p>
|
||||
</div>
|
||||
<span class="icon-[heroicons--fire] size-4 text-orange-500"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Search Results --}}
|
||||
<template x-if="query && !loading">
|
||||
<div>
|
||||
{{-- No Results --}}
|
||||
<template x-if="!hasResults">
|
||||
<div class="p-8 text-center">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-12 text-base-content/20 mb-3 block mx-auto"></span>
|
||||
<p class="text-base-content/60">No results found for "<span x-text="query"></span>"</p>
|
||||
<p class="text-sm text-base-content/40 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Brands Section --}}
|
||||
<template x-if="results.brands && results.brands.length > 0">
|
||||
<div class="border-b border-base-200">
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider px-4 pt-3 pb-2">Brands</h4>
|
||||
<div class="pb-2">
|
||||
<template x-for="(brand, index) in results.brands" :key="brand.id">
|
||||
<a :href="brand.url"
|
||||
:class="{'bg-base-200': selectedIndex === index + 'b'}"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-base-200 transition-colors cursor-pointer"
|
||||
@mouseenter="selectedIndex = index + 'b'"
|
||||
@click="close()">
|
||||
<div class="w-10 h-10 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden flex items-center justify-center">
|
||||
<template x-if="brand.logo_url">
|
||||
<img :src="brand.logo_url" :alt="brand.name" class="w-full h-full object-contain p-1">
|
||||
</template>
|
||||
<template x-if="!brand.logo_url">
|
||||
<span class="text-lg font-bold text-base-content/30" x-text="brand.name.charAt(0)"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" x-text="brand.name"></p>
|
||||
<p class="text-xs text-base-content/60"><span x-text="brand.products_count"></span> products</p>
|
||||
</div>
|
||||
<span class="icon-[heroicons--arrow-right] size-4 text-base-content/40"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Products Section --}}
|
||||
<template x-if="results.products && results.products.length > 0">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider px-4 pt-3 pb-2">Products</h4>
|
||||
<div class="pb-2">
|
||||
<template x-for="(product, index) in results.products" :key="product.id">
|
||||
<a :href="product.url"
|
||||
:class="{'bg-base-200': selectedIndex === index + 'p'}"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-base-200 transition-colors cursor-pointer"
|
||||
@mouseenter="selectedIndex = index + 'p'"
|
||||
@click="close()">
|
||||
<div class="w-12 h-12 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden flex items-center justify-center">
|
||||
<template x-if="product.image_url">
|
||||
<img :src="product.image_url" :alt="product.name" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!product.image_url">
|
||||
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" x-text="product.name"></p>
|
||||
<div class="flex items-center gap-2 text-xs text-base-content/60">
|
||||
<span x-text="product.brand_name"></span>
|
||||
<span>•</span>
|
||||
<span x-text="product.sku"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">$<span x-text="parseFloat(product.price).toFixed(2)"></span></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- View All Results Link --}}
|
||||
<div class="px-4 py-3 border-t border-base-200">
|
||||
<a :href="'{{ route('buyer.browse') }}?search=' + encodeURIComponent(query)"
|
||||
class="btn btn-primary btn-sm w-full"
|
||||
@click="close()">
|
||||
View all results for "<span x-text="query"></span>"
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function marketplaceSearch() {
|
||||
return {
|
||||
query: '',
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
results: { products: [], brands: [] },
|
||||
suggestions: null,
|
||||
selectedIndex: null,
|
||||
|
||||
async onFocus() {
|
||||
if (!this.query) {
|
||||
await this.loadSuggestions();
|
||||
}
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
async loadSuggestions() {
|
||||
if (this.suggestions) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route('buyer.search.suggestions') }}');
|
||||
if (response.ok) {
|
||||
this.suggestions = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load suggestions:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async search() {
|
||||
if (this.query.length < 2) {
|
||||
this.results = { products: [], brands: [] };
|
||||
if (!this.query) {
|
||||
await this.loadSuggestions();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.isOpen = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ route('buyer.search.autocomplete') }}?q=${encodeURIComponent(this.query)}`);
|
||||
if (response.ok) {
|
||||
this.results = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
searchTerm(term) {
|
||||
this.query = term;
|
||||
this.search();
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.results = { products: [], brands: [] };
|
||||
this.selectedIndex = null;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.selectedIndex = null;
|
||||
},
|
||||
|
||||
get hasResults() {
|
||||
return (this.results.products && this.results.products.length > 0) ||
|
||||
(this.results.brands && this.results.brands.length > 0);
|
||||
},
|
||||
|
||||
moveDown() {
|
||||
// Basic keyboard navigation (could be expanded)
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
moveUp() {
|
||||
// Basic keyboard navigation
|
||||
},
|
||||
|
||||
selectCurrent() {
|
||||
if (this.query.length >= 2) {
|
||||
// Navigate to search results page
|
||||
window.location.href = '{{ route('buyer.browse') }}?search=' + encodeURIComponent(this.query);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
100
resources/views/components/marketplace/compare-bar.blade.php
Normal file
100
resources/views/components/marketplace/compare-bar.blade.php
Normal file
@@ -0,0 +1,100 @@
|
||||
{{-- Floating Compare Bar - Shows when products are selected for comparison --}}
|
||||
<div x-data="compareBar()"
|
||||
x-show="count > 0"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform translate-y-full opacity-0"
|
||||
x-transition:enter-end="transform translate-y-0 opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform translate-y-0 opacity-100"
|
||||
x-transition:leave-end="transform translate-y-full opacity-0"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 bg-base-100 border-t border-base-300 shadow-lg py-3 px-4"
|
||||
@compare-updated.window="loadState()">
|
||||
|
||||
<div class="container mx-auto flex items-center justify-between gap-4">
|
||||
{{-- Left side: Count and preview --}}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[heroicons--scale] size-5 text-primary"></span>
|
||||
<span class="font-semibold">
|
||||
<span x-text="count"></span> / <span x-text="max"></span> products
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Product thumbnails --}}
|
||||
<div class="hidden sm:flex items-center -space-x-2">
|
||||
<template x-for="id in ids" :key="id">
|
||||
<div class="w-10 h-10 rounded-lg border-2 border-base-100 bg-base-200 overflow-hidden shadow-sm">
|
||||
{{-- Product image placeholder - would need to fetch images --}}
|
||||
<div class="w-full h-full bg-base-300 flex items-center justify-center">
|
||||
<span class="icon-[heroicons--cube] size-4 text-base-content/30"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right side: Actions --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="clear()"
|
||||
class="btn btn-ghost btn-sm">
|
||||
Clear
|
||||
</button>
|
||||
<a href="{{ route('buyer.compare.index') }}"
|
||||
class="btn btn-primary btn-sm gap-2">
|
||||
<span class="icon-[heroicons--scale] size-4"></span>
|
||||
Compare Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function compareBar() {
|
||||
return {
|
||||
count: 0,
|
||||
max: 4,
|
||||
ids: [],
|
||||
isFull: false,
|
||||
|
||||
init() {
|
||||
this.loadState();
|
||||
},
|
||||
|
||||
async loadState() {
|
||||
try {
|
||||
const response = await fetch('{{ route('buyer.compare.state') }}');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.count = data.count;
|
||||
this.max = data.max;
|
||||
this.ids = data.ids;
|
||||
this.isFull = data.is_full;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load compare state:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
const response = await fetch('{{ route('buyer.compare.clear') }}', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.count = 0;
|
||||
this.ids = [];
|
||||
this.isFull = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear comparison:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
445
resources/views/components/marketplace/product-card.blade.php
Normal file
445
resources/views/components/marketplace/product-card.blade.php
Normal file
@@ -0,0 +1,445 @@
|
||||
@props([
|
||||
'product',
|
||||
'variant' => 'grid', // grid, list, compact
|
||||
'showBrand' => true,
|
||||
'showAddToCart' => true,
|
||||
'business' => null,
|
||||
])
|
||||
|
||||
@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
|
||||
|
||||
@if($variant === 'grid')
|
||||
{{-- GRID VARIANT - Amazon/Shopify style card --}}
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 group relative overflow-hidden"
|
||||
x-data="{
|
||||
inCart: false,
|
||||
quantity: 1,
|
||||
hover: false,
|
||||
productId: {{ $product->id }},
|
||||
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">
|
||||
@if($isNew)
|
||||
<span class="badge badge-primary badge-sm">New</span>
|
||||
@endif
|
||||
@if($product->is_featured)
|
||||
<span class="badge badge-secondary badge-sm">Featured</span>
|
||||
@endif
|
||||
@if(!$product->isInStock())
|
||||
<span class="badge badge-error badge-sm">Sold Out</span>
|
||||
@elseif($product->isLowStock())
|
||||
<span class="badge badge-warning badge-sm">Low Stock</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Product Image with hover actions --}}
|
||||
<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="flex items-center justify-center size-full {{ $hasOwnImage ? '' : 'bg-base-100' }}">
|
||||
@if($imageUrl)
|
||||
@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
|
||||
<span class="icon-[heroicons--cube] size-16 text-base-content/20"></span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
{{-- 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('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>
|
||||
|
||||
<div class="card-body p-4">
|
||||
{{-- Brand --}}
|
||||
@if($showBrand && $product->brand)
|
||||
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}"
|
||||
class="text-xs font-medium uppercase tracking-wide text-primary hover:underline truncate">
|
||||
{{ $product->brand->name }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Product Name --}}
|
||||
<h3 class="font-semibold text-sm line-clamp-2 min-h-[2.5rem]">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="hover:text-primary transition-colors">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{{-- 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($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 Visual Bars --}}
|
||||
@if($product->thc_percentage || $product->cbd_percentage)
|
||||
<div class="space-y-1.5 mt-1">
|
||||
@if($product->thc_percentage)
|
||||
<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)
|
||||
<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 + 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>
|
||||
@if($product->price_unit)
|
||||
<span class="text-xs text-base-content/60">/ {{ $product->price_unit }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($product->units_per_case)
|
||||
<div class="text-[10px] text-base-content/50 mt-0.5">
|
||||
{{ $product->units_per_case }} units/case
|
||||
@if($product->wholesale_price && $product->units_per_case > 1)
|
||||
• ${{ number_format($product->wholesale_price * $product->units_per_case, 2) }}/case
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Stock & Add to Cart --}}
|
||||
@if($showAddToCart)
|
||||
<div class="card-actions pt-3 border-t border-base-200 mt-2">
|
||||
@if($product->isInStock())
|
||||
{{-- Add to cart with qty stepper --}}
|
||||
<div class="join w-full">
|
||||
<button type="button" @click="decrementQty()" class="btn btn-sm join-item" :disabled="quantity <= 1">−</button>
|
||||
<input type="number" x-model.number="quantity" min="1" :max="availableQty"
|
||||
class="input input-sm join-item w-12 text-center px-0 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
||||
<button type="button" @click="incrementQty()" class="btn btn-sm join-item" :disabled="quantity >= availableQty">+</button>
|
||||
<button type="button"
|
||||
@click="$dispatch('add-to-cart', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-primary btn-sm join-item flex-1 gap-1">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
{{-- Backorder with qty stepper --}}
|
||||
<div class="join w-full">
|
||||
<button type="button" @click="decrementQty()" class="btn btn-sm join-item" :disabled="quantity <= 1">−</button>
|
||||
<input type="number" x-model.number="quantity" min="1"
|
||||
class="input input-sm join-item w-12 text-center px-0 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
||||
<button type="button" @click="incrementQty()" class="btn btn-sm join-item">+</button>
|
||||
<button type="button"
|
||||
@click="$dispatch('backorder', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-warning btn-sm join-item flex-1 gap-1">
|
||||
<span class="icon-[heroicons--clock] size-4"></span>
|
||||
Backorder
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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 }},
|
||||
productHashid: '{{ $product->hashid }}',
|
||||
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 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)
|
||||
@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
|
||||
<span class="icon-[heroicons--cube] size-12 text-base-content/20"></span>
|
||||
@endif
|
||||
</a>
|
||||
{{-- Badges --}}
|
||||
<div class="absolute top-2 left-2 flex flex-col gap-1">
|
||||
@if($isNew)
|
||||
<span class="badge badge-primary badge-xs">New</span>
|
||||
@endif
|
||||
@if(!$product->isInStock())
|
||||
<span class="badge badge-error badge-xs">Sold Out</span>
|
||||
@endif
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
{{-- Brand --}}
|
||||
@if($showBrand && $product->brand)
|
||||
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}"
|
||||
class="text-xs font-medium uppercase tracking-wide text-primary hover:underline">
|
||||
{{ $product->brand->name }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
{{-- Product Name --}}
|
||||
<h3 class="font-semibold text-base mt-1">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="hover:text-primary transition-colors">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{{-- Description --}}
|
||||
@if($product->short_description)
|
||||
<p class="text-sm text-base-content/70 mt-2 line-clamp-2">{{ $product->short_description }}</p>
|
||||
@endif
|
||||
|
||||
{{-- 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 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 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>
|
||||
|
||||
{{-- Price & Actions --}}
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-primary">${{ number_format($product->wholesale_price, 2) }}</div>
|
||||
@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())
|
||||
{{-- Add to cart with qty stepper --}}
|
||||
<div class="join">
|
||||
<button type="button" @click="decrementQty()" class="btn btn-sm join-item" :disabled="quantity <= 1">−</button>
|
||||
<input type="number" x-model.number="quantity" min="1" :max="availableQty"
|
||||
class="input input-sm join-item w-12 text-center px-0 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
||||
<button type="button" @click="incrementQty()" class="btn btn-sm join-item" :disabled="quantity >= availableQty">+</button>
|
||||
<button type="button"
|
||||
@click="$dispatch('add-to-cart', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-primary btn-sm join-item gap-1">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
{{-- Backorder with qty stepper --}}
|
||||
<div class="join">
|
||||
<button type="button" @click="decrementQty()" class="btn btn-sm join-item" :disabled="quantity <= 1">−</button>
|
||||
<input type="number" x-model.number="quantity" min="1"
|
||||
class="input input-sm join-item w-12 text-center px-0 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
||||
<button type="button" @click="incrementQty()" class="btn btn-sm join-item">+</button>
|
||||
<button type="button"
|
||||
@click="$dispatch('backorder', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-warning btn-sm join-item gap-1">
|
||||
<span class="icon-[heroicons--clock] size-4"></span>
|
||||
Backorder
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if($product->isInStock() && !$product->isUnlimited())
|
||||
<span class="text-xs text-base-content/60">{{ $product->available_quantity }} available</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@elseif($variant === 'compact')
|
||||
{{-- COMPACT VARIANT - For horizontal scrolling sections --}}
|
||||
<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="flex items-center justify-center size-full {{ $hasOwnImage ? '' : 'bg-base-100' }}">
|
||||
@if($imageUrl)
|
||||
@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
|
||||
<span class="icon-[heroicons--cube] size-10 text-base-content/20"></span>
|
||||
@endif
|
||||
</a>
|
||||
@if($isNew)
|
||||
<span class="badge badge-primary badge-xs absolute top-2 left-2">New</span>
|
||||
@endif
|
||||
</figure>
|
||||
|
||||
<div class="card-body p-3">
|
||||
@if($showBrand && $product->brand)
|
||||
<span class="text-[10px] font-medium uppercase tracking-wide text-primary truncate">
|
||||
{{ $product->brand->name }}
|
||||
</span>
|
||||
@endif
|
||||
<h3 class="font-medium text-sm line-clamp-2 min-h-[2.5rem]">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="hover:text-primary">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="text-lg font-bold text-primary">${{ number_format($product->wholesale_price, 2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -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>
|
||||
@@ -1,73 +0,0 @@
|
||||
@props(['business' => null, 'active' => null])
|
||||
|
||||
@php
|
||||
// Get business from props or route parameter
|
||||
$navBusiness = $business ?? request()->route('business');
|
||||
|
||||
// Get unread counts for badges
|
||||
$unreadInboxCount = 0;
|
||||
$pendingOrderCount = 0;
|
||||
|
||||
if ($navBusiness) {
|
||||
$unreadInboxCount = \App\Models\Crm\CrmThread::forBusiness($navBusiness->id)
|
||||
->where('is_read', false)
|
||||
->where('last_message_direction', 'inbound')
|
||||
->count();
|
||||
|
||||
$pendingOrderCount = \App\Models\Order::where('status', 'pending')
|
||||
->whereHas('items.product.brand', fn($q) => $q->where('business_id', $navBusiness->id))
|
||||
->count();
|
||||
}
|
||||
|
||||
// Determine active tab from current route
|
||||
$currentRoute = request()->route()?->getName() ?? '';
|
||||
$activeTab = $active ?? match(true) {
|
||||
str_contains($currentRoute, 'dashboard') => 'dashboard',
|
||||
str_contains($currentRoute, 'inbox') || str_contains($currentRoute, 'threads') => 'inbox',
|
||||
str_contains($currentRoute, 'orders') => 'orders',
|
||||
str_contains($currentRoute, 'deals') || str_contains($currentRoute, 'pipeline') => 'pipeline',
|
||||
default => null,
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if($navBusiness && auth()->check() && auth()->user()->user_type !== 'buyer')
|
||||
<nav class="btm-nav btm-nav-sm lg:hidden z-50 bg-base-100 border-t border-base-300 pb-safe">
|
||||
{{-- Dashboard --}}
|
||||
<a href="{{ route('seller.business.dashboard', $navBusiness->slug) }}"
|
||||
class="{{ $activeTab === 'dashboard' ? 'active text-primary' : 'text-base-content/70' }}">
|
||||
<span class="icon-[heroicons--home{{ $activeTab === 'dashboard' ? '' : '-outline' }}] w-5 h-5"></span>
|
||||
<span class="btm-nav-label text-xs">Home</span>
|
||||
</a>
|
||||
|
||||
{{-- Inbox --}}
|
||||
<a href="{{ route('seller.business.crm.inbox', $navBusiness->slug) }}"
|
||||
class="relative {{ $activeTab === 'inbox' ? 'active text-primary' : 'text-base-content/70' }}">
|
||||
<span class="icon-[heroicons--chat-bubble-left-right{{ $activeTab === 'inbox' ? '' : '-outline' }}] w-5 h-5"></span>
|
||||
<span class="btm-nav-label text-xs">Inbox</span>
|
||||
@if($unreadInboxCount > 0)
|
||||
<span class="badge badge-xs badge-success absolute top-0 right-1/4 translate-x-1/2">
|
||||
{{ $unreadInboxCount > 99 ? '99+' : $unreadInboxCount }}
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
{{-- Orders --}}
|
||||
<a href="{{ route('seller.business.orders.index', $navBusiness->slug) }}"
|
||||
class="relative {{ $activeTab === 'orders' ? 'active text-primary' : 'text-base-content/70' }}">
|
||||
<span class="icon-[heroicons--shopping-bag{{ $activeTab === 'orders' ? '' : '-outline' }}] w-5 h-5"></span>
|
||||
<span class="btm-nav-label text-xs">Orders</span>
|
||||
@if($pendingOrderCount > 0)
|
||||
<span class="badge badge-xs badge-warning absolute top-0 right-1/4 translate-x-1/2">
|
||||
{{ $pendingOrderCount > 99 ? '99+' : $pendingOrderCount }}
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
{{-- Pipeline --}}
|
||||
<a href="{{ route('seller.business.crm.deals.index', $navBusiness->slug) }}"
|
||||
class="{{ $activeTab === 'pipeline' ? 'active text-primary' : 'text-base-content/70' }}">
|
||||
<span class="icon-[heroicons--funnel{{ $activeTab === 'pipeline' ? '' : '-outline' }}] w-5 h-5"></span>
|
||||
<span class="btm-nav-label text-xs">Pipeline</span>
|
||||
</a>
|
||||
</nav>
|
||||
@endif
|
||||
@@ -1,82 +0,0 @@
|
||||
{{-- Pull-to-Refresh Component - Mobile touch gesture support --}}
|
||||
<div x-data="pullToRefresh()" x-init="init()">
|
||||
{{-- Pull indicator --}}
|
||||
<div x-show="isPulling"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 -translate-y-full"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
:class="{ 'bg-primary/10': pullDistance > threshold }"
|
||||
class="fixed top-0 left-0 right-0 flex items-center justify-center py-3 z-50 bg-base-200/80 backdrop-blur-sm lg:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-show="pullDistance <= threshold" class="icon-[heroicons--arrow-down] w-5 h-5 text-base-content/70"></span>
|
||||
<span x-show="pullDistance > threshold" class="loading loading-spinner loading-sm text-primary"></span>
|
||||
<span class="text-sm text-base-content/70" x-text="pullDistance > threshold ? 'Release to refresh' : 'Pull to refresh'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function pullToRefresh() {
|
||||
return {
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
threshold: 80,
|
||||
startY: 0,
|
||||
enabled: true,
|
||||
|
||||
init() {
|
||||
// Only enable on touch devices
|
||||
if (!('ontouchstart' in window)) {
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
|
||||
document.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||
document.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true });
|
||||
},
|
||||
|
||||
handleTouchStart(e) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Only start tracking if at the top of the page
|
||||
if (window.scrollY === 0) {
|
||||
this.startY = e.touches[0].pageY;
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchMove(e) {
|
||||
if (!this.enabled || this.startY === 0) return;
|
||||
|
||||
const currentY = e.touches[0].pageY;
|
||||
const diff = currentY - this.startY;
|
||||
|
||||
// Only track downward pulls when at the top
|
||||
if (diff > 0 && window.scrollY === 0) {
|
||||
// Apply some resistance to the pull
|
||||
this.pullDistance = Math.min(diff * 0.5, 150);
|
||||
this.isPulling = this.pullDistance > 10;
|
||||
|
||||
// Prevent default scrolling behavior when pulling
|
||||
if (this.isPulling) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchEnd() {
|
||||
if (!this.enabled) return;
|
||||
|
||||
if (this.pullDistance > this.threshold) {
|
||||
// Trigger refresh
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.isPulling = false;
|
||||
this.pullDistance = 0;
|
||||
this.startY = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,89 +0,0 @@
|
||||
{{-- PWA Install Prompt - Mobile only --}}
|
||||
<div x-data="pwaInstallPrompt()" x-show="showPrompt" x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-4"
|
||||
class="fixed bottom-20 left-4 right-4 lg:hidden z-40">
|
||||
<div class="alert bg-base-100 shadow-lg border border-base-300">
|
||||
<span class="icon-[heroicons--device-phone-mobile] w-6 h-6 text-primary"></span>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-sm">Install App</h3>
|
||||
<p class="text-xs text-base-content/70">Add to home screen for the best experience</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="dismiss()" class="btn btn-sm btn-ghost">Later</button>
|
||||
<button @click="install()" class="btn btn-sm btn-primary">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function pwaInstallPrompt() {
|
||||
return {
|
||||
showPrompt: false,
|
||||
deferredPrompt: null,
|
||||
|
||||
init() {
|
||||
// Check if already dismissed recently (within 7 days)
|
||||
const dismissedAt = localStorage.getItem('pwa-install-dismissed');
|
||||
if (dismissedAt) {
|
||||
const daysSinceDismissed = (Date.now() - parseInt(dismissedAt)) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceDismissed < 7) {
|
||||
return; // Don't show if dismissed within last 7 days
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
return; // Already installed as PWA
|
||||
}
|
||||
|
||||
// Listen for the beforeinstallprompt event
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
// Stash the event so it can be triggered later
|
||||
this.deferredPrompt = e;
|
||||
// Show our custom install prompt
|
||||
this.showPrompt = true;
|
||||
});
|
||||
|
||||
// Listen for successful install
|
||||
window.addEventListener('appinstalled', () => {
|
||||
this.showPrompt = false;
|
||||
this.deferredPrompt = null;
|
||||
});
|
||||
},
|
||||
|
||||
async install() {
|
||||
if (!this.deferredPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the install prompt
|
||||
this.deferredPrompt.prompt();
|
||||
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await this.deferredPrompt.userChoice;
|
||||
|
||||
// Clear the deferred prompt
|
||||
this.deferredPrompt = null;
|
||||
this.showPrompt = false;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
// User accepted the install
|
||||
console.log('PWA installed successfully');
|
||||
}
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
// Store dismissal time
|
||||
localStorage.setItem('pwa-install-dismissed', Date.now().toString());
|
||||
this.showPrompt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -309,7 +309,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div id="layout-content" class="pb-16 lg:pb-0">
|
||||
<div id="layout-content">
|
||||
<!-- Flash Messages (unless page uses toasts) -->
|
||||
@if(!isset($useToasts) || !$useToasts)
|
||||
@if(session('success'))
|
||||
@@ -522,12 +522,7 @@
|
||||
@auth
|
||||
@if(auth()->user()->user_type === 'buyer')
|
||||
<x-marketplace-chat-widget />
|
||||
@else
|
||||
{{-- Mobile Bottom Navigation (sellers only) --}}
|
||||
<x-mobile-bottom-nav />
|
||||
@endif
|
||||
{{-- PWA Install Prompt (all authenticated users, mobile only) --}}
|
||||
<x-pwa-install-prompt />
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{-- PWA Support: Manifest + Service Worker Registration + Update Toast --}}
|
||||
{{-- PWA Support: Manifest + Service Worker Registration + Update Toast + Install Prompt --}}
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#22c55e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -21,15 +21,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- PWA Install Prompt (hidden by default) --}}
|
||||
<div id="pwa-install-prompt" class="toast toast-end toast-bottom z-[9998] hidden">
|
||||
<div class="alert bg-base-100 border border-base-300 shadow-lg">
|
||||
<span class="icon-[heroicons--device-phone-mobile] size-5 text-primary"></span>
|
||||
<div>
|
||||
<span class="font-medium">Install Hub</span>
|
||||
<p class="text-xs text-base-content/60">Add to your home screen for quick access</p>
|
||||
</div>
|
||||
<button id="pwa-install-btn" class="btn btn-primary btn-sm">Install</button>
|
||||
<button onclick="window.dismissInstallPrompt()" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--x-mark] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// PWA Service Worker Registration and Update Detection
|
||||
// PWA Service Worker Registration, Update Detection, and Install Prompt
|
||||
(function() {
|
||||
// Skip service worker in local development (conflicts with Vite HMR)
|
||||
// Force unregister all service workers on localhost (conflicts with Vite HMR)
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
console.log('SW skipped in development');
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
registration.unregister();
|
||||
console.log('SW unregistered:', registration.scope);
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ============ SERVICE WORKER ============
|
||||
if ('serviceWorker' in navigator) {
|
||||
let refreshing = false;
|
||||
let newWorker = null;
|
||||
@@ -45,6 +68,12 @@
|
||||
navigator.serviceWorker.register('/sw.js').then(registration => {
|
||||
console.log('SW registered:', registration.scope);
|
||||
|
||||
// Check for updates on page load if there's a waiting worker
|
||||
if (registration.waiting) {
|
||||
document.getElementById('pwa-update-toast').classList.remove('hidden');
|
||||
newWorker = registration.waiting;
|
||||
}
|
||||
|
||||
// Check for updates periodically (every 5 minutes)
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
@@ -73,5 +102,67 @@
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============ INSTALL PROMPT ============
|
||||
let deferredPrompt = null;
|
||||
const installPromptKey = 'pwa-install-dismissed';
|
||||
|
||||
// Check if user already dismissed or installed
|
||||
function shouldShowInstallPrompt() {
|
||||
// Don't show if already in standalone mode (installed)
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
return false;
|
||||
}
|
||||
// Don't show if dismissed in last 7 days
|
||||
const dismissed = localStorage.getItem(installPromptKey);
|
||||
if (dismissed) {
|
||||
const dismissedDate = new Date(parseInt(dismissed));
|
||||
const daysSince = (Date.now() - dismissedDate) / (1000 * 60 * 60 * 24);
|
||||
if (daysSince < 7) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Capture the install prompt event
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
if (shouldShowInstallPrompt()) {
|
||||
// Delay showing prompt by 30 seconds so user has time to use the app first
|
||||
setTimeout(() => {
|
||||
if (deferredPrompt && shouldShowInstallPrompt()) {
|
||||
document.getElementById('pwa-install-prompt').classList.remove('hidden');
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle install button click
|
||||
document.getElementById('pwa-install-btn')?.addEventListener('click', async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
document.getElementById('pwa-install-prompt').classList.add('hidden');
|
||||
deferredPrompt.prompt();
|
||||
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log('PWA install outcome:', outcome);
|
||||
deferredPrompt = null;
|
||||
});
|
||||
|
||||
// Dismiss and remember
|
||||
window.dismissInstallPrompt = function() {
|
||||
document.getElementById('pwa-install-prompt').classList.add('hidden');
|
||||
localStorage.setItem(installPromptKey, Date.now().toString());
|
||||
};
|
||||
|
||||
// Detect successful install
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('PWA installed');
|
||||
document.getElementById('pwa-install-prompt').classList.add('hidden');
|
||||
deferredPrompt = null;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
376
resources/views/seller/brands/cannaiq-mapping.blade.php
Normal file
376
resources/views/seller/brands/cannaiq-mapping.blade.php
Normal file
@@ -0,0 +1,376 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('title', $brand->name . ' - CannaiQ Product Mapping')
|
||||
|
||||
@section('content')
|
||||
<div class="px-4 py-6" x-data="cannaiqMapping()">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{ route('seller.business.brands.dashboard', [$business->slug, $brand->hashid]) }}"
|
||||
class="btn btn-ghost btn-sm gap-1">
|
||||
<span class="icon-[heroicons--arrow-left] size-4"></span>
|
||||
Back
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">CannaiQ Product Mapping</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Map {{ $brand->name }} products to CannaiQ market data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="badge badge-info gap-1">
|
||||
<span class="icon-[heroicons--link] size-3"></span>
|
||||
Connected as: {{ $brand->cannaiq_brand_key }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="stats shadow mb-6">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Hub Products</div>
|
||||
<div class="stat-value text-primary">{{ $products->count() }}</div>
|
||||
<div class="stat-desc">Active products</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Mapped</div>
|
||||
<div class="stat-value text-success">{{ $products->filter(fn($p) => $p->cannaiqMappings->count() > 0)->count() }}</div>
|
||||
<div class="stat-desc">Products with mappings</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Unmapped</div>
|
||||
<div class="stat-value text-warning">{{ $products->filter(fn($p) => $p->cannaiqMappings->count() === 0)->count() }}</div>
|
||||
<div class="stat-desc">Need attention</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Search CannaiQ Products --}}
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Search CannaiQ Products</h2>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Search for products in CannaiQ's database to map to your Hub products.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="form-control flex-1">
|
||||
<div class="join w-full">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@keyup.enter="searchCannaiq()"
|
||||
class="input input-bordered join-item flex-1"
|
||||
placeholder="Search by product name...">
|
||||
<button type="button"
|
||||
@click="searchCannaiq()"
|
||||
:disabled="isSearching || !searchQuery.trim()"
|
||||
class="btn btn-primary join-item gap-2">
|
||||
<span x-show="!isSearching" class="icon-[heroicons--magnifying-glass] size-4"></span>
|
||||
<span x-show="isSearching" class="loading loading-spinner loading-xs"></span>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Search Results --}}
|
||||
<div x-show="searchResults.length > 0" class="mt-4">
|
||||
<h3 class="font-medium mb-2">Search Results (<span x-text="searchResults.length"></span>)</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product Name</th>
|
||||
<th>Brand</th>
|
||||
<th>Store</th>
|
||||
<th>Price</th>
|
||||
<th>Stock</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="result in searchResults" :key="result.id">
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<span x-text="result.name" class="font-medium"></span>
|
||||
</td>
|
||||
<td x-text="result.brand_name || '-'"></td>
|
||||
<td x-text="result.store_name || '-'" class="text-xs"></td>
|
||||
<td x-text="result.price ? '$' + result.price : '-'"></td>
|
||||
<td>
|
||||
<span x-show="result.in_stock" class="badge badge-success badge-xs">In Stock</span>
|
||||
<span x-show="!result.in_stock" class="badge badge-error badge-xs">OOS</span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
@click="openMappingModal(result)"
|
||||
class="btn btn-xs btn-primary gap-1">
|
||||
<span class="icon-[heroicons--link] size-3"></span>
|
||||
Map
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="searchResults.length === 0 && hasSearched" class="mt-4 text-center text-base-content/60">
|
||||
No products found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Products List --}}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="card-title">Your Products</h2>
|
||||
<div class="form-control">
|
||||
<select x-model="filterStatus" class="select select-bordered select-sm">
|
||||
<option value="all">All Products</option>
|
||||
<option value="mapped">Mapped Only</option>
|
||||
<option value="unmapped">Unmapped Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>SKU</th>
|
||||
<th>CannaiQ Mappings</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($products as $product)
|
||||
<tr x-show="shouldShowProduct({{ $product->cannaiqMappings->count() }})" class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($product->getImageUrl('thumb'))
|
||||
<div class="avatar">
|
||||
<div class="mask mask-squircle w-10 h-10">
|
||||
<img src="{{ $product->getImageUrl('thumb') }}" alt="{{ $product->name }}">
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="font-medium">{{ $product->name }}</div>
|
||||
<div class="text-xs text-base-content/60">{{ $product->short_description ?? '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-xs bg-base-200 px-2 py-0.5 rounded">{{ $product->sku }}</code>
|
||||
</td>
|
||||
<td>
|
||||
@if($product->cannaiqMappings->count() > 0)
|
||||
<div class="space-y-1">
|
||||
@foreach($product->cannaiqMappings as $mapping)
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="icon-[heroicons--link] size-3 text-success"></span>
|
||||
<span class="truncate max-w-xs" title="{{ $mapping->cannaiq_product_name }}">
|
||||
{{ Str::limit($mapping->cannaiq_product_name, 40) }}
|
||||
</span>
|
||||
@if($mapping->cannaiq_store_name)
|
||||
<span class="badge badge-ghost badge-xs">{{ $mapping->cannaiq_store_name }}</span>
|
||||
@endif
|
||||
<button type="button"
|
||||
@click="removeMapping({{ $mapping->id }})"
|
||||
class="btn btn-ghost btn-xs text-error">
|
||||
<span class="icon-[heroicons--x-mark] size-3"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-base-content/40 text-sm">No mappings</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
@click="selectHubProduct({{ $product->id }}, '{{ addslashes($product->name) }}')"
|
||||
class="btn btn-sm btn-outline gap-1">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
Add Mapping
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mapping Modal --}}
|
||||
<dialog id="mapping_modal" class="modal" x-ref="mappingModal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Map Product</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Hub Product --}}
|
||||
<div class="p-3 bg-base-200 rounded-lg">
|
||||
<p class="text-xs text-base-content/60 mb-1">Hub Product</p>
|
||||
<p class="font-medium" x-text="selectedHubProduct?.name || 'Select a product'"></p>
|
||||
</div>
|
||||
|
||||
{{-- CannaiQ Product --}}
|
||||
<div class="p-3 bg-info/10 rounded-lg">
|
||||
<p class="text-xs text-info mb-1">CannaiQ Product</p>
|
||||
<p class="font-medium" x-text="selectedCannaiqProduct?.name || 'Select from search results'"></p>
|
||||
<p x-show="selectedCannaiqProduct?.store_name" class="text-xs text-base-content/60 mt-1">
|
||||
Store: <span x-text="selectedCannaiqProduct?.store_name"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" @click="closeMappingModal()" class="btn btn-ghost">Cancel</button>
|
||||
<button type="button"
|
||||
@click="saveMapping()"
|
||||
:disabled="!selectedHubProduct || !selectedCannaiqProduct || isSaving"
|
||||
class="btn btn-primary gap-2">
|
||||
<span x-show="isSaving" class="loading loading-spinner loading-xs"></span>
|
||||
Save Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function cannaiqMapping() {
|
||||
return {
|
||||
// Search
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
hasSearched: false,
|
||||
|
||||
// Filter
|
||||
filterStatus: 'all',
|
||||
|
||||
// Mapping
|
||||
selectedHubProduct: null,
|
||||
selectedCannaiqProduct: null,
|
||||
isSaving: false,
|
||||
|
||||
shouldShowProduct(mappingCount) {
|
||||
if (this.filterStatus === 'all') return true;
|
||||
if (this.filterStatus === 'mapped') return mappingCount > 0;
|
||||
if (this.filterStatus === 'unmapped') return mappingCount === 0;
|
||||
return true;
|
||||
},
|
||||
|
||||
async searchCannaiq() {
|
||||
if (!this.searchQuery.trim()) return;
|
||||
|
||||
this.isSearching = true;
|
||||
this.hasSearched = true;
|
||||
|
||||
try {
|
||||
// Search CannaiQ products filtered by brand
|
||||
const brandKey = '{{ $brand->cannaiq_brand_key }}';
|
||||
const response = await fetch(`https://cannaiq.co/api/v1/products?q=${encodeURIComponent(this.searchQuery)}&brand=${encodeURIComponent(brandKey)}&limit=50`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
this.searchResults = data.products || [];
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
this.searchResults = [];
|
||||
}
|
||||
|
||||
this.isSearching = false;
|
||||
},
|
||||
|
||||
selectHubProduct(id, name) {
|
||||
this.selectedHubProduct = { id, name };
|
||||
this.selectedCannaiqProduct = null;
|
||||
// If we have search results, show modal
|
||||
if (this.searchResults.length > 0) {
|
||||
document.getElementById('mapping_modal').showModal();
|
||||
}
|
||||
},
|
||||
|
||||
openMappingModal(cannaiqProduct) {
|
||||
this.selectedCannaiqProduct = cannaiqProduct;
|
||||
document.getElementById('mapping_modal').showModal();
|
||||
},
|
||||
|
||||
closeMappingModal() {
|
||||
document.getElementById('mapping_modal').close();
|
||||
this.selectedHubProduct = null;
|
||||
this.selectedCannaiqProduct = null;
|
||||
},
|
||||
|
||||
async saveMapping() {
|
||||
if (!this.selectedHubProduct || !this.selectedCannaiqProduct) return;
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route('seller.business.brands.cannaiq.map', [$business->slug, $brand->hashid]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_id: this.selectedHubProduct.id,
|
||||
cannaiq_product_id: this.selectedCannaiqProduct.id,
|
||||
cannaiq_product_name: this.selectedCannaiqProduct.name,
|
||||
cannaiq_store_id: this.selectedCannaiqProduct.store_id || null,
|
||||
cannaiq_store_name: this.selectedCannaiqProduct.store_name || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Reload page to show updated mappings
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to save mapping: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
alert('Failed to save mapping. Please try again.');
|
||||
}
|
||||
|
||||
this.isSaving = false;
|
||||
},
|
||||
|
||||
async removeMapping(mappingId) {
|
||||
if (!confirm('Remove this mapping?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ url('/s/' . $business->slug . '/brands/' . $brand->hashid . '/cannaiq/map') }}/${mappingId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -2204,6 +2204,233 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CannaiQ Integration -->
|
||||
<div class="card bg-base-100 shadow lg:col-span-2" x-data="{
|
||||
isConnected: {{ $brand->isCannaiqConnected() ? 'true' : 'false' }},
|
||||
cannaiqKey: '{{ $brand->cannaiq_brand_key ?? '' }}',
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
|
||||
// Brand search
|
||||
searchQuery: '',
|
||||
allBrands: [],
|
||||
filteredBrands: [],
|
||||
selectedBrand: null,
|
||||
isLoading: false,
|
||||
showDropdown: false,
|
||||
|
||||
async init() {
|
||||
await this.loadBrands();
|
||||
},
|
||||
|
||||
async loadBrands() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await fetch('https://cannaiq.co/api/v1/brands', {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.brands) {
|
||||
this.allBrands = data.brands;
|
||||
this.filteredBrands = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load CannaiQ brands:', err);
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
filterBrands() {
|
||||
if (!this.searchQuery || this.searchQuery.length < 2) {
|
||||
this.filteredBrands = [];
|
||||
this.showDropdown = false;
|
||||
return;
|
||||
}
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
this.filteredBrands = this.allBrands
|
||||
.filter(b => b.brand.toLowerCase().includes(query))
|
||||
.slice(0, 20);
|
||||
this.showDropdown = this.filteredBrands.length > 0;
|
||||
},
|
||||
|
||||
selectBrand(brand) {
|
||||
this.selectedBrand = brand;
|
||||
this.searchQuery = brand.brand;
|
||||
this.showDropdown = false;
|
||||
},
|
||||
|
||||
async connect() {
|
||||
if (!this.selectedBrand) {
|
||||
this.connectionError = 'Please select a brand from the dropdown';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
this.connectionError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route('seller.business.brands.cannaiq.connect', [$business->slug, $brand->hashid]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ brand_name: this.selectedBrand.brand })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.isConnected = true;
|
||||
this.cannaiqKey = data.cannaiq_brand_key;
|
||||
} else {
|
||||
this.connectionError = data.error || 'Failed to connect';
|
||||
}
|
||||
} catch (err) {
|
||||
this.connectionError = 'Connection failed. Please try again.';
|
||||
}
|
||||
|
||||
this.isConnecting = false;
|
||||
},
|
||||
|
||||
async disconnect() {
|
||||
if (!confirm('Disconnect from CannaiQ? You will lose access to market intelligence data.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route('seller.business.brands.cannaiq.disconnect', [$business->slug, $brand->hashid]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.isConnected = false;
|
||||
this.cannaiqKey = '';
|
||||
this.selectedBrand = null;
|
||||
this.searchQuery = '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Disconnect failed:', err);
|
||||
}
|
||||
}
|
||||
}">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="card-title">CannaiQ Integration</h2>
|
||||
<span x-show="isConnected" class="badge badge-info badge-sm gap-1">
|
||||
<span class="icon-[heroicons--check-circle] size-3"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Connect your brand to CannaiQ for real-time market intelligence, competitor analysis, and inventory tracking across dispensaries.
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
{{-- Not Connected State --}}
|
||||
<div x-show="!isConnected" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Select your brand from CannaiQ</span>
|
||||
</label>
|
||||
|
||||
{{-- Loading state --}}
|
||||
<div x-show="isLoading" class="flex items-center gap-2 text-base-content/60">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Loading CannaiQ brands...
|
||||
</div>
|
||||
|
||||
{{-- Search input with dropdown --}}
|
||||
<div x-show="!isLoading" class="relative">
|
||||
<div class="join w-full">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterBrands()"
|
||||
@focus="filterBrands()"
|
||||
@click.away="showDropdown = false"
|
||||
class="input input-bordered join-item flex-1"
|
||||
placeholder="Type to search brands...">
|
||||
<button type="button"
|
||||
@click="connect()"
|
||||
:disabled="isConnecting || !selectedBrand"
|
||||
class="btn btn-primary join-item gap-2">
|
||||
<span x-show="!isConnecting" class="icon-[heroicons--link] size-4"></span>
|
||||
<span x-show="isConnecting" class="loading loading-spinner loading-xs"></span>
|
||||
<span x-text="isConnecting ? 'Connecting...' : 'Connect'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Dropdown results --}}
|
||||
<div x-show="showDropdown"
|
||||
x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
<template x-for="brand in filteredBrands" :key="brand.brand">
|
||||
<button type="button"
|
||||
@click="selectBrand(brand)"
|
||||
class="w-full px-4 py-2 text-left hover:bg-base-200 flex justify-between items-center">
|
||||
<span x-text="brand.brand" class="font-medium"></span>
|
||||
<span class="text-xs text-base-content/60">
|
||||
<span x-text="brand.product_count"></span> products
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Selected brand indicator --}}
|
||||
<div x-show="selectedBrand" class="mt-2 flex items-center gap-2 text-sm text-success">
|
||||
<span class="icon-[heroicons--check-circle] size-4"></span>
|
||||
Selected: <span x-text="selectedBrand?.brand" class="font-medium"></span>
|
||||
(<span x-text="selectedBrand?.in_stock_count"></span> in stock)
|
||||
</div>
|
||||
|
||||
<label class="label" x-show="connectionError">
|
||||
<span class="label-text-alt text-error" x-text="connectionError"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Connected State --}}
|
||||
<div x-show="isConnected" class="space-y-3">
|
||||
<div class="flex items-center justify-between p-4 bg-base-200 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium flex items-center gap-2">
|
||||
<span class="icon-[heroicons--check-circle] size-5 text-success"></span>
|
||||
Connected as: <code class="text-sm bg-base-300 px-2 py-0.5 rounded" x-text="cannaiqKey"></code>
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Your brand is now connected to CannaiQ market intelligence.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click="disconnect()"
|
||||
class="btn btn-ghost btn-sm text-error">
|
||||
<span class="icon-[heroicons--x-mark] size-4"></span>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.brands.stores.index', [$business->slug, $brand->hashid]) }}"
|
||||
class="btn btn-outline btn-sm gap-1">
|
||||
<span class="icon-[heroicons--building-storefront] size-4"></span>
|
||||
View Store Data
|
||||
</a>
|
||||
<a href="{{ route('seller.business.brands.analysis', [$business->slug, $brand->hashid]) }}"
|
||||
class="btn btn-outline btn-sm gap-1">
|
||||
<span class="icon-[heroicons--chart-bar] size-4"></span>
|
||||
Market Analysis
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Settings -->
|
||||
<div class="card bg-base-100 shadow lg:col-span-2">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -300,6 +300,10 @@
|
||||
<span x-show="brand.is_featured" class="badge badge-warning badge-xs">Featured</span>
|
||||
<span x-show="brand.is_public && !brand.is_featured" class="badge badge-ghost badge-xs">Public</span>
|
||||
<span x-show="!brand.is_public" class="badge badge-ghost badge-xs">Private</span>
|
||||
<span x-show="brand.is_cannaiq_connected" class="badge badge-info badge-xs gap-0.5">
|
||||
<span class="icon-[heroicons--chart-bar-square] size-3"></span>
|
||||
CannaiQ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
@else
|
||||
<span class="badge badge-ghost text-neutral border-base-300 badge-sm">Inactive</span>
|
||||
@endif
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-primary btn-sm gap-1">
|
||||
<div class="dropdown dropdown-end" x-data>
|
||||
<div tabindex="0" role="button" class="btn btn-primary btn-sm gap-1">
|
||||
<span class="icon-[heroicons--bolt] size-4"></span>
|
||||
Actions
|
||||
<span class="icon-[heroicons--chevron-down] size-3"></span>
|
||||
</label>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 rounded-xl w-48 border border-base-200">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.crm.accounts.edit', [$business->slug, $account->slug]) }}" class="gap-2 text-sm">
|
||||
@@ -44,7 +44,7 @@
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="$dispatch('open-create-opportunity-modal', { account_id: {{ $account->id }}, account_locked: true })" class="gap-2 text-sm">
|
||||
<button type="button" @click="$dispatch('open-create-opportunity-modal', { account_id: {{ $account->id }}, account_locked: true })" class="gap-2 text-sm w-full text-left">
|
||||
<span class="icon-[heroicons--sparkles] size-4 text-warning"></span>
|
||||
New Opportunity
|
||||
</button>
|
||||
@@ -56,13 +56,13 @@
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="document.getElementById('note-section').scrollIntoView({behavior:'smooth'})" class="gap-2 text-sm">
|
||||
<button type="button" @click="document.getElementById('note-section').scrollIntoView({behavior:'smooth'})" class="gap-2 text-sm w-full text-left">
|
||||
<span class="icon-[heroicons--pencil] size-4 text-base-content/60"></span>
|
||||
Add Note
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="$dispatch('open-send-menu-modal', { customer_id: {{ $account->id }}, customer_locked: true })" class="gap-2 text-sm">
|
||||
<button type="button" @click="$dispatch('open-send-menu-modal', { customer_id: {{ $account->id }}, customer_locked: true })" class="gap-2 text-sm w-full text-left">
|
||||
<span class="icon-[heroicons--paper-airplane] size-4 text-primary"></span>
|
||||
Send Menu
|
||||
</button>
|
||||
|
||||
@@ -817,8 +817,10 @@ function calendarApp() {
|
||||
|
||||
editFromDetail() {
|
||||
if (this.selectedEvent && this.selectedEvent.extendedProps.editable) {
|
||||
// Store event before closing detail (closeDetail sets selectedEvent to null)
|
||||
const eventToEdit = this.selectedEvent;
|
||||
this.closeDetail();
|
||||
this.openEditModal(this.selectedEvent);
|
||||
this.openEditModal(eventToEdit);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
@section('title', 'Deals - ' . $business->name)
|
||||
|
||||
@section('content')
|
||||
<x-pull-to-refresh />
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
|
||||
{{-- Header --}}
|
||||
<header class="flex items-center justify-between">
|
||||
@@ -53,14 +52,14 @@
|
||||
</div>
|
||||
|
||||
{{-- Pipeline Board --}}
|
||||
<div class="overflow-x-auto pb-4 -mx-4 px-4 lg:mx-0 lg:px-0 snap-x snap-mandatory scrollbar-thin scrollbar-thumb-base-300">
|
||||
<div class="overflow-x-auto pb-4">
|
||||
<div class="flex gap-4" style="min-width: max-content;">
|
||||
@foreach($pipeline->stages as $stage)
|
||||
@php
|
||||
$stageDeals = $deals->get($stage['name']) ?? collect();
|
||||
$stageValue = $stageDeals->sum('value');
|
||||
@endphp
|
||||
<div class="w-72 flex-shrink-0 snap-start">
|
||||
<div class="w-72 flex-shrink-0">
|
||||
{{-- Stage Header --}}
|
||||
<div class="flex items-center justify-between mb-3 p-3 bg-base-200 rounded-t-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -103,23 +102,11 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
@if($deal->expected_close_date)
|
||||
<div class="text-xs text-base-content/50">
|
||||
Close: {{ $deal->expected_close_date->format('M j') }}
|
||||
</div>
|
||||
@else
|
||||
<div></div>
|
||||
@endif
|
||||
@if($deal->stage_changed_at)
|
||||
@php
|
||||
$daysInStage = $deal->stage_changed_at->diffInDays(now());
|
||||
@endphp
|
||||
<div class="badge badge-xs {{ $daysInStage > 14 ? 'badge-warning' : ($daysInStage > 7 ? 'badge-ghost' : 'badge-success') }}">
|
||||
{{ $daysInStage }}d
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if($deal->expected_close_date)
|
||||
<div class="text-xs text-base-content/50 mt-1">
|
||||
Close: {{ $deal->expected_close_date->format('M j') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
{{-- Page Header --}}
|
||||
<header class="mb-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-tight">
|
||||
<h1 class="text-2xl font-semibold leading-tight">
|
||||
Invoice <span class="font-mono">{{ $invoice->invoice_number }}</span>
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/70">{{ $invoice->title }}</p>
|
||||
<p class="text-sm text-base-content/70">Created on {{ $invoice->created_at->format('F j, Y \a\t g:i A') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -86,148 +86,169 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- Top Summary Grid (3 Cards) --}}
|
||||
{{-- Top Summary Grid (3 Columns: Buyer | Seller | Invoice Info) --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||
|
||||
{{-- Invoice Details --}}
|
||||
{{-- BUYER INFORMATION --}}
|
||||
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
|
||||
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
|
||||
<span class="icon-[heroicons--document-text] size-4 text-base-content/60"></span>
|
||||
<h2 class="text-sm font-semibold">Invoice Details</h2>
|
||||
<header class="px-4 py-3 border-b border-base-200">
|
||||
<h2 class="text-sm font-semibold text-primary uppercase tracking-wide">Buyer Information</h2>
|
||||
</header>
|
||||
<div class="px-4 py-4 space-y-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Invoice Number</p>
|
||||
<p class="font-medium font-mono">{{ $invoice->invoice_number }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Invoice Date</p>
|
||||
<p class="font-medium">{{ ($invoice->invoice_date ?? $invoice->created_at)->format('M j, Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Due Date</p>
|
||||
<p class="font-medium {{ $invoice->isOverdue() ? 'text-error' : '' }}">
|
||||
{{ $invoice->due_date ? $invoice->due_date->format('M j, Y') : '—' }}
|
||||
@if($invoice->isOverdue())
|
||||
<span class="badge badge-error badge-xs ml-1">Overdue</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@if($invoice->deal)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Related Deal</p>
|
||||
<a href="{{ route('seller.business.crm.deals.show', [$business, $invoice->deal]) }}" class="link link-primary text-sm">
|
||||
{{ $invoice->deal->title }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@if($invoice->quote)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">From Quote</p>
|
||||
<a href="{{ route('seller.business.crm.quotes.show', [$business, $invoice->quote]) }}" class="link link-primary text-sm">
|
||||
{{ $invoice->quote->quote_number }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@if($invoice->order)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Related Order</p>
|
||||
<a href="{{ route('seller.business.orders.show', [$business->slug, $invoice->order]) }}" class="link link-primary text-sm">
|
||||
{{ $invoice->order->order_number }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Customer Information --}}
|
||||
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
|
||||
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
|
||||
<span class="icon-[heroicons--building-storefront] size-4 text-base-content/60"></span>
|
||||
<h2 class="text-sm font-semibold">Customer</h2>
|
||||
</header>
|
||||
<div class="px-4 py-4 space-y-2 text-sm">
|
||||
@if($invoice->account)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Account</p>
|
||||
<p class="text-xs text-base-content/60">Store/Dispensary Name</p>
|
||||
<a href="{{ route('seller.business.crm.accounts.show', [$business, $invoice->account]) }}" class="font-medium link link-primary">
|
||||
{{ $invoice->account->name }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">License</p>
|
||||
<p class="font-mono text-xs">{{ $invoice->account->license_number ?? 'N/A' }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if($invoice->contact)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Contact</p>
|
||||
<p class="font-medium">{{ $invoice->contact->first_name }} {{ $invoice->contact->last_name }}</p>
|
||||
<p class="text-xs text-base-content/60">Contact Details</p>
|
||||
<p class="font-medium">Contact Person: {{ $invoice->contact->first_name }} {{ $invoice->contact->last_name }}</p>
|
||||
@if($invoice->contact->email)
|
||||
<p>Email: <a href="mailto:{{ $invoice->contact->email }}" class="link link-primary">{{ $invoice->contact->email }}</a></p>
|
||||
@endif
|
||||
@if($invoice->contact->phone)
|
||||
<p>Phone: {{ $invoice->contact->phone }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if($invoice->account?->billing_address)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Billing Address</p>
|
||||
<p>{{ $invoice->account->billing_address }}</p>
|
||||
</div>
|
||||
@if($invoice->contact->email)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Email</p>
|
||||
<a href="mailto:{{ $invoice->contact->email }}" class="link link-primary text-sm">
|
||||
{{ $invoice->contact->email }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@if($invoice->contact->phone)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Phone</p>
|
||||
<a href="tel:{{ $invoice->contact->phone }}" class="link link-primary text-sm">
|
||||
{{ $invoice->contact->phone }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@if($invoice->location)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Location</p>
|
||||
<p class="font-medium">{{ $invoice->location->name }}</p>
|
||||
<p class="text-xs text-base-content/60">Delivery Address</p>
|
||||
<p class="font-medium">Location: {{ $invoice->location->name }}</p>
|
||||
@if($invoice->location->address)
|
||||
<p>{{ $invoice->location->address }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Payment Summary --}}
|
||||
{{-- SELLER INFORMATION --}}
|
||||
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
|
||||
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
|
||||
<span class="icon-[heroicons--banknotes] size-4 text-base-content/60"></span>
|
||||
<h2 class="text-sm font-semibold">Payment Summary</h2>
|
||||
<header class="px-4 py-3 border-b border-base-200">
|
||||
<h2 class="text-sm font-semibold text-primary uppercase tracking-wide">Seller Information</h2>
|
||||
</header>
|
||||
<div class="px-4 py-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Invoice Total</span>
|
||||
<span class="font-semibold">${{ number_format($invoice->total, 2) }}</span>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Brand Name</p>
|
||||
<p class="font-medium">{{ $business->dba_name ?? $business->name }}</p>
|
||||
</div>
|
||||
@if($invoice->amount_paid > 0)
|
||||
<div class="flex justify-between text-success">
|
||||
<span>Paid</span>
|
||||
<span class="font-semibold">-${{ number_format($invoice->amount_paid, 2) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex justify-between pt-2 border-t border-base-200 {{ $invoice->balance_due > 0 ? ($invoice->isOverdue() ? 'text-error' : 'text-warning') : 'text-success' }}">
|
||||
<span class="font-semibold">Balance Due</span>
|
||||
<span class="font-bold text-lg">${{ number_format($invoice->balance_due, 2) }}</span>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">License</p>
|
||||
<p class="font-mono text-xs">{{ $business->license_number ?? 'N/A' }}</p>
|
||||
</div>
|
||||
@if($invoice->balance_due <= 0)
|
||||
<div class="flex items-center gap-2 text-success">
|
||||
<span class="icon-[heroicons--check-circle] size-5"></span>
|
||||
<span class="font-medium">Paid in Full</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Payment Progress --}}
|
||||
@if($invoice->total > 0)
|
||||
<div class="pt-2">
|
||||
<div class="flex justify-between text-xs text-base-content/60 mb-1">
|
||||
<span>Payment Progress</span>
|
||||
<span>{{ number_format($invoice->getPaymentProgress(), 0) }}%</span>
|
||||
</div>
|
||||
<progress class="progress {{ $invoice->balance_due <= 0 ? 'progress-success' : 'progress-primary' }} w-full" value="{{ $invoice->getPaymentProgress() }}" max="100"></progress>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Sales Representative</p>
|
||||
<p class="font-medium">{{ $invoice->creator?->name ?? 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Contact Details</p>
|
||||
@if($business->contact_email)
|
||||
<p>Email: {{ $business->contact_email }}</p>
|
||||
@endif
|
||||
@if($business->contact_phone)
|
||||
<p>Phone: {{ $business->contact_phone }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if($business->address)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Billing Address</p>
|
||||
<p>{{ $business->address }}</p>
|
||||
@if($business->city || $business->state || $business->zip)
|
||||
<p>{{ $business->city }}, {{ $business->state }} {{ $business->zip }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- INVOICE INFORMATION --}}
|
||||
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
|
||||
<header class="px-4 py-3 border-b border-base-200 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-primary uppercase tracking-wide">Invoice Information</h2>
|
||||
<span class="badge {{ $invoice->getStatusBadgeClass() }} badge-sm">{{ ucfirst($invoice->status) }}</span>
|
||||
</header>
|
||||
<div class="px-4 py-4 text-sm">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{{-- Left: Invoice Details --}}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Invoice Number</p>
|
||||
<p class="font-medium font-mono">{{ $invoice->invoice_number }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Invoice Created</p>
|
||||
<p class="font-medium">{{ ($invoice->invoice_date ?? $invoice->created_at)->format('d F Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Manually Created By</p>
|
||||
<p class="font-medium">{{ $invoice->creator?->name ?? 'System' }}</p>
|
||||
</div>
|
||||
@if($invoice->order)
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Related Order</p>
|
||||
<a href="{{ route('seller.business.orders.show', [$business->slug, $invoice->order]) }}" class="link link-primary font-mono text-xs">
|
||||
{{ $invoice->order->order_number }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Right: Payment Details --}}
|
||||
<div class="space-y-3 border-l border-base-200 pl-4">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Payment Terms</p>
|
||||
<p class="font-medium">{{ $invoice->payment_terms ?? 'Net 30' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Payment Due Date</p>
|
||||
<p class="font-medium {{ $invoice->isOverdue() ? 'text-error' : '' }}">
|
||||
{{ $invoice->due_date ? $invoice->due_date->format('d M Y') : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Payment Status</p>
|
||||
<span class="badge badge-sm {{ $invoice->balance_due <= 0 ? 'badge-success' : ($invoice->isOverdue() ? 'badge-error' : 'badge-warning') }}">
|
||||
{{ $invoice->balance_due <= 0 ? 'PAID' : 'UNPAID' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pt-2 border-t border-base-200 space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="tabular-nums">${{ number_format($invoice->subtotal, 2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Total Discounts</span>
|
||||
<span class="tabular-nums">${{ number_format($invoice->discount_amount ?? 0, 2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/60">Tax</span>
|
||||
<span class="tabular-nums">${{ number_format($invoice->tax_amount ?? 0, 2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between font-semibold pt-1 border-t border-base-200">
|
||||
<span>Total</span>
|
||||
<span class="tabular-nums">${{ number_format($invoice->total, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
@@ -235,44 +256,69 @@
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
{{-- Line Items --}}
|
||||
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
|
||||
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
|
||||
<span class="icon-[heroicons--list-bullet] size-4 text-base-content/60"></span>
|
||||
<h2 class="text-sm font-semibold">Line Items</h2>
|
||||
<header class="px-4 py-3 border-b border-base-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[heroicons--list-bullet] size-4 text-base-content/60"></span>
|
||||
<h2 class="text-sm font-semibold">Line Items</h2>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60">{{ $invoice->items->count() }} {{ Str::plural('item', $invoice->items->count()) }}</span>
|
||||
</header>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12">#</th>
|
||||
<th>Product/Description</th>
|
||||
<th class="text-right w-20">Qty</th>
|
||||
<th class="text-right w-28">Unit Price</th>
|
||||
<tr class="bg-base-200/50">
|
||||
<th class="text-xs font-semibold text-base-content/70 w-10 text-center">#</th>
|
||||
<th class="text-xs font-semibold text-base-content/70">Product</th>
|
||||
<th class="text-xs font-semibold text-base-content/70">Brand</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 text-center">Quantity</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 text-right">Price</th>
|
||||
@if($invoice->items->sum('discount_percent') > 0)
|
||||
<th class="text-right w-20">Disc</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 text-right">Disc</th>
|
||||
@endif
|
||||
<th class="text-right w-28">Amount</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@php
|
||||
$totalUnits = 0;
|
||||
$grandTotal = 0;
|
||||
@endphp
|
||||
@foreach($invoice->items as $index => $item)
|
||||
@php
|
||||
$lineTotal = $item->quantity * $item->unit_price * (1 - ($item->discount_percent ?? 0) / 100);
|
||||
$unitsPerCase = $item->product?->units_per_case ?? 1;
|
||||
$totalUnits += $item->quantity * $unitsPerCase;
|
||||
$grandTotal += $lineTotal;
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="text-base-content/50">{{ $index + 1 }}</td>
|
||||
<td>
|
||||
<div class="font-medium">{{ $item->product?->name ?? $item->description }}</div>
|
||||
@if($item->product?->sku)
|
||||
<div class="text-xs text-base-content/50">SKU: {{ $item->product->sku }}</div>
|
||||
<tr class="hover">
|
||||
<td class="text-center text-xs text-base-content/50 font-mono">{{ $index + 1 }}</td>
|
||||
<td class="py-2.5">
|
||||
<p class="text-sm font-medium leading-tight">{{ $item->product?->name ?? $item->description }}</p>
|
||||
@if($item->product?->units_per_case && $item->product->units_per_case > 1)
|
||||
<p class="text-xs text-base-content/60">({{ $item->product->units_per_case }} units / case)</p>
|
||||
@endif
|
||||
@if($item->product?->brand)
|
||||
<div class="text-xs text-base-content/50">{{ $item->product->brand->name }}</div>
|
||||
@if($item->product?->sku)
|
||||
<p class="text-xs text-base-content/50 font-mono">{{ $item->product->sku }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-right tabular-nums">{{ number_format($item->quantity, 2) }}</td>
|
||||
<td class="text-right tabular-nums">${{ number_format($item->unit_price, 2) }}</td>
|
||||
<td class="py-2.5 text-sm">
|
||||
{{ $item->product?->brand?->name ?? '—' }}
|
||||
</td>
|
||||
<td class="py-2.5 text-center">
|
||||
@if($item->product?->units_per_case && $item->product->units_per_case > 1)
|
||||
@php
|
||||
$cases = floor($item->quantity / $item->product->units_per_case);
|
||||
$units = $item->quantity;
|
||||
@endphp
|
||||
<span class="text-sm">{{ number_format($units, 0) }} UNITS</span>
|
||||
<p class="text-xs text-base-content/60">({{ $cases }} {{ Str::plural('CASE', $cases) }})</p>
|
||||
@else
|
||||
<span class="text-sm">{{ number_format($item->quantity, 0) }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2.5 text-sm text-right tabular-nums">${{ number_format($item->unit_price, 2) }}</td>
|
||||
@if($invoice->items->sum('discount_percent') > 0)
|
||||
<td class="text-right tabular-nums text-success">
|
||||
<td class="py-2.5 text-right tabular-nums text-success">
|
||||
@if($item->discount_percent > 0)
|
||||
{{ $item->discount_percent }}%
|
||||
@else
|
||||
@@ -280,10 +326,35 @@
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td class="text-right font-medium tabular-nums">${{ number_format($lineTotal, 2) }}</td>
|
||||
<td class="py-2.5 text-sm text-right font-semibold tabular-nums">${{ number_format($lineTotal, 2) }}</td>
|
||||
</tr>
|
||||
@if($item->item_comment)
|
||||
<tr class="bg-base-200/30">
|
||||
<td></td>
|
||||
<td colspan="{{ $invoice->items->sum('discount_percent') > 0 ? 5 : 4 }}" class="py-2 px-4">
|
||||
<div class="text-xs text-primary font-semibold uppercase mb-1">Item Comments</div>
|
||||
<div class="bg-base-100 rounded p-2 text-sm">{{ $item->item_comment }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bg-base-200/30 border-t-2 border-base-300">
|
||||
<td colspan="3" class="py-3">
|
||||
<span class="text-xs text-base-content/60">
|
||||
{{ $invoice->items->count() }} {{ Str::plural('line item', $invoice->items->count()) }} •
|
||||
{{ number_format($totalUnits) }} total units
|
||||
</span>
|
||||
</td>
|
||||
<td colspan="{{ $invoice->items->sum('discount_percent') > 0 ? 3 : 2 }}" class="py-3 text-right">
|
||||
<span class="text-sm font-semibold text-base-content/70">Subtotal</span>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<span class="text-base font-bold tabular-nums">${{ number_format($grandTotal, 2) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
Quotes
|
||||
</a>
|
||||
|
||||
{{-- Invoices --}}
|
||||
<a href="{{ route('seller.business.crm.invoices.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-300 {{ request()->routeIs('seller.business.crm.invoices.*') ? 'bg-primary text-primary-content' : '' }}">
|
||||
{{-- Invoices - link to main invoices, not CRM invoices --}}
|
||||
<a href="{{ route('seller.business.invoices.index', $business->slug) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-300">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z" />
|
||||
</svg>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user