Compare commits

...

57 Commits

Author SHA1 Message Date
kelly
f652ccba90 fix: resolve Crystal's issues #19, #18, #17, #11, #8
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Issue #19 - Product descriptions not showing in preview:
- Updated getStoryHtmlAttribute() to check consumer_long_description
  and buyer_long_description fields, not just long_description

Issues #18 & #17 - Product images not displaying after upload:
- Added new route /images/product-image/{productImage}/{width?}
- Added productImageById() method to ImageController
- Updated edit.blade.php and ProductImageController to use new route
- Each ProductImage now has its own unique URL instead of using product hashid

Issues #11 & #8 - LazyLoadingViolation when saving quotes/invoices:
- Removed auto-recalculate hooks from CrmQuoteItem and CrmInvoiceItem
- Controllers already call calculateTotals() explicitly after saving items
- Prevents lazy loading violations during item delete/create cycles
2025-12-18 09:46:48 -07:00
kelly
782e6797d8 fix: pass brand hashid as query param to product create
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-18 09:33:40 -07:00
kelly
09ff7b27a5 feat: add New Product button with brand selector to Products page 2025-12-18 09:27:47 -07:00
kelly
fe994205f2 fix: clean up duplicate categories, assign White Label Canna to Bulk
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
- Merge duplicate parent categories (Accessories, Concentrates, etc.)
- Deactivate duplicate category records
- Move products/children to the kept category
- Assign White Label Canna products to Bulk category (147)
- Mark White Label Canna products as is_raw_material=true
2025-12-18 08:54:22 -07:00
kelly
cf80a8b681 style: use DaisyUI join component for qty stepper
Replaces awkward inline qty controls with proper DaisyUI join
component for a cleaner, more polished button group appearance
2025-12-18 08:20:39 -07:00
kelly
5bdd6a2225 feat: enhance product cards with backorder support and editable qty
- Add Backorder button with qty selector for out-of-stock items
- Replace static qty display with editable number input
- Add backorder event handler to marketplace page
- Update both grid and list variants with consistent behavior
- Qty can now be typed directly, not just incremented/decremented
2025-12-18 08:17:54 -07:00
kelly
93678e59bc feat: implement buyer Deals page with active promotions
- Add deals() method to MarketplaceController
- Wire up existing deals.blade.php view with proper data
- Add promotions() relationship to Brand model
- Update buyer sidebar: rename 'Promotion' to 'Deals', link to route

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

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

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

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

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

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

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

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

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

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

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

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

BannerAd Fix:
- Handle missing banner_ads table gracefully
- Check Schema::hasTable before querying
- Wrap in try-catch to prevent page errors
2025-12-17 22:53:47 -07:00
kelly
c92cd230d5 fix: correct BannerAdResource navigationGroup type hint
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 22:33:07 -07:00
kelly
bcbfdd3c91 feat: add banner ad system with zones, scheduling, and analytics
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Banner Ad System:
- BannerAd model with scheduling (starts_at, ends_at)
- BannerAdEvent model for impression/click tracking
- BannerAdDailyStat model for analytics rollups
- BannerAdZone enum (6 zones: hero, leaderboard, sidebar, inline, brand, deals)
- BannerAdStatus enum (draft, active, scheduled, paused, expired)

Service & Controller:
- BannerAdService with weighted random rotation, caching
- BannerAdController for click tracking and image serving
- Routes for /ads/click, /ads/impression, /images/banner-ad

Filament Admin:
- Full CRUD resource at /admin/banner-ads
- Image upload to MinIO
- Status/zone filters
- Analytics display (impressions, clicks, CTR)

Display Components:
- <x-banner-ad zone="..." /> Blade component
- Automatic impression tracking
- Click tracking via redirect
- Sponsored badge overlay

View Placements:
- Marketplace homepage: leaderboard + sidebar
- Brand page: banner below breadcrumbs
- Deals page: hero banner

Background Jobs:
- UpdateBannerAdStatuses: activate scheduled, expire ended (every minute)
- RollupBannerAdStats: daily aggregation + event cleanup
2025-12-17 22:18:27 -07:00
kelly
3c21093e66 fix: eager load brand relationship for product cards on brand page
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 21:52:57 -07:00
kelly
e4e0a19873 feat: redesign brand page with Amazon/Shopify style UI
- Hero banner section with gradient overlay and logo
- Brand details section below hero (stats, social links, description)
- Featured products horizontal scroll section
- Menu/category tabs for product filtering
- Grid/list view toggle with localStorage persistence
- Reusable product-card component for all product displays
- Breadcrumb navigation
2025-12-17 21:52:31 -07:00
kelly
5b0503abf5 fix: resolve marketplace query issues
- Fix PostgreSQL having clause error by using collection filtering instead
- Use category_id with ProductCategory model instead of deprecated category string
- Eager load category relationship to prevent lazy loading violations
- Update view to use category_id parameter and ProductCategory names
2025-12-17 21:48:18 -07:00
kelly
cc8aab7ee1 fix: add PWA partial to auth-layout component
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
PWA was not working on login/register pages because the auth-layout
component was missing the @include('partials.pwa') directive.
2025-12-17 21:25:04 -07:00
kelly
2c1f7d093f feat: enterprise accounting UI harmonization for orders/invoices
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add 3-column header layout (Buyer | Seller | Document Info) to:
  - Order create page (new)
  - Invoice create page (updated)
  - Order show page (updated with units/cases, item comments)
  - Invoice show page (updated with seller info, units/cases)
  - Quote show page (updated with seller info, units/cases)

- Add seller-initiated order creation:
  - New /orders/create route and view
  - Orders track created_by (buyer/seller)
  - New Order button on orders index

- Add ping pong order flow feature:
  - ping_pong_enabled on businesses table
  - is_ping_pong toggle per order
  - Admin toggle in Business > Suite Settings

- Add item comments per line item:
  - item_comment field on order_items, crm_invoice_items, crm_quote_items
  - Inline edit UI on order show page

- UI improvements:
  - Units/cases display (X UNITS / Y CASES)
  - Live totals in document headers
  - Consistent styling across all document types
2025-12-17 19:01:13 -07:00
kelly
11a07692ad feat: add CannaiQ product mapping
- Add product_cannaiq_mappings pivot table (many-to-many)
- Add ProductCannaiqMapping model
- Add cannaiqMappings relationship to Product model
- Add mapping page at /s/{business}/brands/{brand}/cannaiq
- Add map/unmap API endpoints
- Update brand settings CannaiQ section with searchable brand dropdown
- Search CannaiQ products and map to Hub products
2025-12-17 18:58:08 -07:00
kelly
05754c9d5b feat: add CannaiQ brand integration
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add cannaiq_brand_key column to brands table for API integration
- Add Brand model methods: isCannaiqConnected(), connectToCannaiq(), disconnectFromCannaiq()
- Add CannaiQ badge to brand tiles on index page
- Add CannaiQ Integration settings section in brand dashboard
- Add connect/disconnect routes and controller methods
2025-12-17 18:25:37 -07:00
kelly
e26da88f22 fix: correct location_id validation in InvoiceController
Change validation from nullable|integer to exists:locations,id
to properly validate against the locations table.
2025-12-17 16:49:13 -07:00
kelly
b703b27676 feat: PWA install prompt, update notifications, and CRM fixes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
PWA:
- Add install prompt toast (appears after 30 seconds)
- Remembers dismissal for 7 days
- Fix: check for waiting SW on page load for update banner

CRM fixes:
- QuoteController: eager load items.product.brand to prevent N+1
- InvoiceController: change location_id validation to nullable integer
2025-12-17 16:43:36 -07:00
kelly
5dffd96187 feat: add select all checkbox for permission groups in user edit
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 16:22:49 -07:00
kelly
9ff1f2b37b fix: service worker self-destructs on localhost
Some checks failed
ci/woodpecker/push/ci Pipeline failed
SW now checks hostname at startup and unregisters itself on localhost/127.0.0.1.
Prevents dev environment issues with Vite HMR and double-click navigation.
2025-12-17 16:21:34 -07:00
kelly
23ad9f2824 fix: resolve Crystal's issues #14, #11, #8
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Issue #14 - Calendar Edit modal disappears:
- Store event reference before closing detail modal
- closeDetail() was setting selectedEvent to null before openEditModal used it

Issue #11 - Quote edit LazyLoadingViolation:
- CrmQuoteItem booted hooks now check relationLoaded() before accessing quote
- Prevents lazy loading when quote relation not eager-loaded

Issue #8 - Invoice Cannot Submit (location_id column missing):
- Add migration to add location_id column to crm_invoices table
- Make crm_invoice_items.name nullable (controller doesn't provide it)
- CrmInvoiceItem has same lazy loading fix
2025-12-17 16:09:09 -07:00
kelly
b90cb829c9 Merge branch 'fix/product-description-emoji-import' into develop
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 15:20:52 -07:00
kelly
69d9174314 Merge branch 'fix/crystal-issues' into develop
# Conflicts:
#	app/Http/Controllers/Seller/Crm/ThreadController.php
2025-12-17 15:14:42 -07:00
kelly
c350ecbb3c Merge branch 'feat/dashboard-pwa-enhancements' into develop
# Conflicts:
#	.woodpecker/.ci.yml
2025-12-17 15:13:43 -07:00
kelly
06098c7013 fix: prevent lazy loading violations in CRM item models and calendar edit 2025-12-17 15:13:11 -07:00
kelly
709321383c fix(ci): clear bootstrap cache and verify test command
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Clear bootstrap/cache/*.php to force package discovery
- Add verification step to confirm artisan test is available
2025-12-17 13:55:48 -07:00
kelly
7506466c38 fix(ci): rm -rf vendor before composer install
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Ensures dev dependencies like parallel-lint are installed fresh
instead of using stale cached vendor from Docker image.
2025-12-17 13:52:42 -07:00
kelly
c84455a11b fix: force unregister service workers on localhost
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Actively unregister existing service workers on localhost instead of just
skipping registration. Fixes double-click navigation issue caused by
stale SW intercepting requests during local development.
2025-12-17 13:12:34 -07:00
kelly
2e7fff135c fix(ci): use development env and PostgreSQL for unit tests
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Change APP_ENV from production to development (installs dev dependencies)
- Change unit tests from SQLite to PostgreSQL (matches feature tests)
- Fixes parallel-lint not found error
2025-12-17 13:12:12 -07:00
kelly
a1a8e3ee9c fix(ci): point feature tests to primary DB (10.100.7.50)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
10.100.6.50 is a read-only replica, tests need write access.
2025-12-17 13:10:14 -07:00
kelly
72ab5d8baa fix(tests): move service tests to Feature suite
Some checks failed
ci/woodpecker/push/ci Pipeline failed
These tests use database factories which require the DB schema.
Unit tests run with SQLite in-memory (no migrations), so tests
that need real DB records must be in Feature suite (PostgreSQL).
2025-12-17 13:08:58 -07:00
kelly
fc715c6022 feat: add DBA (Doing Business As) entity system for sellers
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Implement complete DBA management allowing businesses to operate under
multiple trade names with separate addresses, licenses, banking info,
and invoice branding.

Database:
- Create business_dbas table with encrypted bank/tax fields
- Add dba_id foreign key to crm_invoices

Models:
- BusinessDba model with encrypted casts, auto-slug, single-default enforcement
- Business model: dbas(), activeDbas(), defaultDba(), getDbaForInvoice()
- CrmInvoice model: dba() relationship, getSellerDisplayInfo() method

Seller UI:
- DbaController with full CRUD + set-default + toggle-active
- Index/create/edit views using DaisyUI
- Trade Names card added to settings index

Admin:
- DbasRelationManager for BusinessResource in Filament

Migration:
- MigrateDbaData command to convert existing dba_name fields
2025-12-17 12:50:07 -07:00
kelly
32a00493f8 fix(ci): explicitly set DB_CONNECTION=pgsql in phpunit.xml
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
CI was falling back to sqlite because DB_CONNECTION wasn't set.
This caused all database-dependent tests to fail with 'no such table'.
2025-12-17 10:37:52 -07:00
kelly
829d4c6b6c fix: correct users table column names in unified inbox query
The users table has first_name/last_name columns, not a name column.
The User model has a name accessor that concatenates these, but SQL
queries must reference the actual column names.
2025-12-16 17:26:27 -07:00
kelly
db2386b8a9 feat: add Nuvata products to missing_products.php
Added 8 Nuvata products (NU-*) to the data file so they get created
on production without needing MySQL connection.
2025-12-12 09:14:52 -07:00
122 changed files with 10981 additions and 2070 deletions

View File

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

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Buyer\BuyerBrandFollow;
use App\Models\OrderItem;
use App\Services\Cannaiq\MarketingIntelligenceService;
use Illuminate\Http\Request;
class BuyAgainController extends Controller
{
public function index(Request $request, Business $business)
{
$tab = $request->get('tab', 'favorites'); // 'favorites' or 'history'
if ($tab === 'favorites') {
$brands = $this->getFavoriteBrands($business);
} else {
$brands = $this->getPurchaseHistory($business);
}
// Optional: Enrich with CannaIQ inventory data if business has it
$storeMetrics = null;
if ($business->cannaiq_store_id) {
$storeMetrics = $this->getStoreInventory($business, $brands);
}
return view('buyer.buy-again.index', compact('business', 'brands', 'tab', 'storeMetrics'));
}
private function getFavoriteBrands(Business $business)
{
// Get brands the buyer follows
$followedBrandIds = BuyerBrandFollow::where('business_id', $business->id)
->pluck('brand_id');
if ($followedBrandIds->isEmpty()) {
return collect();
}
// Get products from those brands that user has ordered
return Brand::whereIn('id', $followedBrandIds)
->with(['products' => function ($query) use ($business) {
$query->whereHas('orderItems.order', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['orderItems' => function ($q) use ($business) {
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
->latest()
->limit(1);
}])
->where('is_active', true);
}])
->get()
->filter(fn ($brand) => $brand->products->isNotEmpty());
}
private function getPurchaseHistory(Business $business)
{
// Get all products ever ordered, grouped by brand
$orderedProductIds = OrderItem::whereHas('order', function ($q) use ($business) {
$q->where('business_id', $business->id);
})->distinct()->pluck('product_id');
if ($orderedProductIds->isEmpty()) {
return collect();
}
return Brand::whereHas('products', fn ($q) => $q->whereIn('id', $orderedProductIds))
->with(['products' => function ($query) use ($orderedProductIds, $business) {
$query->whereIn('id', $orderedProductIds)
->with(['orderItems' => function ($q) use ($business) {
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
->latest()
->limit(1);
}]);
}])
->get();
}
private function getStoreInventory(Business $business, $brands)
{
if ($brands->isEmpty()) {
return null;
}
$productIds = $brands->flatMap(fn ($b) => $b->products->pluck('id'));
try {
$cannaiq = app(MarketingIntelligenceService::class);
return $cannaiq->getStoreMetrics($business->cannaiq_store_id, $productIds->toArray());
} catch (\Exception $e) {
// Silently fail if CannaIQ unavailable
return null;
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\ProductComparisonService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CompareController extends Controller
{
public function __construct(
protected ProductComparisonService $comparison
) {}
/**
* Show the comparison page.
*/
public function index(): View
{
$products = $this->comparison->getProducts();
$business = auth()->user()->businesses->first();
return view('buyer.compare.index', compact('products', 'business'));
}
/**
* Get current comparison state (AJAX).
*/
public function state(): JsonResponse
{
return response()->json([
'ids' => $this->comparison->getProductIds(),
'count' => $this->comparison->count(),
'is_full' => $this->comparison->isFull(),
'max' => $this->comparison->maxItems(),
]);
}
/**
* Toggle a product in the comparison list (AJAX).
*/
public function toggle(Product $product): JsonResponse
{
if (! $product->is_active) {
return response()->json(['error' => 'Product not found'], 404);
}
$result = $this->comparison->toggle($product->id);
return response()->json([
'added' => $result['added'],
'count' => $result['count'],
'is_full' => $this->comparison->isFull(),
'message' => $result['added']
? 'Added to comparison'
: 'Removed from comparison',
]);
}
/**
* Remove a product from comparison list (AJAX).
*/
public function remove(Product $product): JsonResponse
{
$this->comparison->remove($product->id);
return response()->json([
'count' => $this->comparison->count(),
'is_full' => $this->comparison->isFull(),
]);
}
/**
* Clear the comparison list.
*/
public function clear(): JsonResponse
{
$this->comparison->clear();
return response()->json([
'count' => 0,
'is_full' => false,
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Buyer\BuyerMessageSettings;
use App\Models\Crm\CrmThread;
use Illuminate\Http\Request;
@@ -10,9 +11,8 @@ use Illuminate\Support\Facades\Auth;
class InboxController extends Controller
{
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$filter = $request->get('filter', 'all');
@@ -20,7 +20,7 @@ class InboxController extends Controller
$query = CrmThread::forBuyerBusiness($business->id)
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount(['messages', 'unreadMessages as unread_count' => fn ($q) => $q->unreadForBuyer()]);
->withCount('messages');
// Apply filters
$query = match ($filter) {
@@ -54,6 +54,7 @@ class InboxController extends Controller
];
return view('buyer.crm.inbox.index', compact(
'business',
'threads',
'filter',
'search',
@@ -62,9 +63,8 @@ class InboxController extends Controller
));
}
public function show(CrmThread $thread)
public function show(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
// Verify thread belongs to this buyer
if ($thread->buyer_business_id !== $business->id) {
@@ -84,9 +84,8 @@ class InboxController extends Controller
return view('buyer.crm.inbox.show', compact('thread'));
}
public function compose(Request $request)
public function compose(Request $request, Business $business)
{
$business = Auth::user()->business;
// Get brands the buyer has ordered from or can message
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
@@ -107,7 +106,7 @@ class InboxController extends Controller
));
}
public function store(Request $request)
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
@@ -117,7 +116,6 @@ class InboxController extends Controller
'quote_id' => 'nullable|exists:crm_quotes,id',
]);
$business = Auth::user()->business;
$user = Auth::user();
// Create thread
@@ -143,9 +141,8 @@ class InboxController extends Controller
->with('success', 'Message sent successfully.');
}
public function star(CrmThread $thread)
public function star(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
$user = Auth::user();
if ($thread->buyer_business_id !== $business->id) {
@@ -157,9 +154,8 @@ class InboxController extends Controller
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
}
public function archive(CrmThread $thread)
public function archive(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
$user = Auth::user();
if ($thread->buyer_business_id !== $business->id) {
@@ -172,9 +168,8 @@ class InboxController extends Controller
->with('success', 'Conversation archived.');
}
public function unarchive(CrmThread $thread)
public function unarchive(Business $business, CrmThread $thread)
{
$business = Auth::user()->business;
$user = Auth::user();
if ($thread->buyer_business_id !== $business->id) {
@@ -186,9 +181,8 @@ class InboxController extends Controller
return back()->with('success', 'Conversation restored.');
}
public function markAllRead()
public function markAllRead(Business $business)
{
$business = Auth::user()->business;
CrmThread::forBuyerBusiness($business->id)
->hasUnreadForBuyer()

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
class ProductController extends Controller
{
/**
* Get quick view data for a product (AJAX endpoint).
*/
public function quickView(Product $product): JsonResponse
{
// Only return active products
if (! $product->is_active) {
return response()->json(['error' => 'Product not found'], 404);
}
// Get the product's brand
$product->load('brand:id,name,slug');
return response()->json([
'id' => $product->id,
'hashid' => $product->hashid,
'name' => $product->name,
'sku' => $product->sku,
'description' => $product->short_description ?? $product->description,
'price' => $product->wholesale_price ?? 0,
'price_unit' => $product->price_unit,
'thc_percentage' => $product->thc_percentage,
'cbd_percentage' => $product->cbd_percentage,
'in_stock' => $product->isInStock(),
'available_quantity' => $product->quantity_on_hand,
'image_url' => $product->getImageUrl('medium'),
'brand_name' => $product->brand?->name,
'brand_slug' => $product->brand?->slug,
'brand_url' => $product->brand ? route('buyer.brands.show', $product->brand->slug) : null,
'url' => $product->brand ? route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) : null,
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Buyer Search Controller
*
* Provides search autocomplete endpoints for the marketplace header.
*/
class SearchController extends Controller
{
/**
* Search autocomplete for products and brands.
*
* GET /b/search/autocomplete?q=...
*
* Returns products and brands matching the query for dropdown suggestions.
*/
public function autocomplete(Request $request): JsonResponse
{
$query = trim($request->input('q', ''));
if (strlen($query) < 2) {
return response()->json(['products' => [], 'brands' => []]);
}
// Search products (limit 8)
$products = Product::query()
->where('is_active', true)
->whereHas('brand', fn ($q) => $q->where('is_active', true))
->with('brand:id,name,slug')
->where(function ($q) use ($query) {
$q->where('name', 'ILIKE', "%{$query}%")
->orWhere('sku', 'ILIKE', "%{$query}%")
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
})
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
->orderBy('name')
->limit(8)
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price', 'image_path']);
// Search brands (limit 4)
$brands = Brand::query()
->where('is_active', true)
->where(function ($q) use ($query) {
$q->where('name', 'ILIKE', "%{$query}%")
->orWhere('description', 'ILIKE', "%{$query}%");
})
->withCount('products')
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
->orderBy('name')
->limit(4)
->get(['id', 'name', 'slug', 'logo_path']);
return response()->json([
'products' => $products->map(fn ($p) => [
'id' => $p->id,
'hashid' => $p->hashid,
'name' => $p->name,
'sku' => $p->sku,
'price' => $p->wholesale_price ?? 0,
'image_url' => $p->getImageUrl('thumb'),
'brand_name' => $p->brand?->name,
'brand_slug' => $p->brand?->slug,
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
]),
'brands' => $brands->map(fn ($b) => [
'id' => $b->id,
'name' => $b->name,
'slug' => $b->slug,
'logo_url' => $b->getLogoUrl('thumb'),
'products_count' => $b->products_count,
'url' => route('buyer.brands.show', $b->slug),
]),
]);
}
/**
* Search suggestions based on popular searches.
*
* GET /b/search/suggestions
*
* Returns popular search terms and trending products.
*/
public function suggestions(): JsonResponse
{
// Popular search terms (could be tracked and stored, for now use static list)
$popularTerms = [
'gummies',
'vape',
'flower',
'indica',
'sativa',
'edibles',
'pre-roll',
'concentrate',
];
// Trending products (recently added or best sellers)
$trending = Product::query()
->where('is_active', true)
->whereHas('brand', fn ($q) => $q->where('is_active', true))
->with('brand:id,name,slug')
->orderBy('created_at', 'desc')
->limit(4)
->get(['id', 'brand_id', 'name', 'image_path']);
return response()->json([
'terms' => $popularTerms,
'trending' => $trending->map(fn ($p) => [
'id' => $p->id,
'hashid' => $p->hashid,
'name' => $p->name,
'image_url' => $p->getImageUrl('thumb'),
'brand_name' => $p->brand?->name,
'brand_slug' => $p->brand?->slug,
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
]),
]);
}
}

View File

@@ -53,6 +53,39 @@ use Intervention\Image\ImageManager;
*/
class ImageController extends Controller
{
/**
* Cache duration for images (1 year in seconds)
*/
private const CACHE_TTL = 31536000;
/**
* Return a cached response for an image
*/
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
{
$response = response($contents)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
if ($etag) {
$response->header('ETag', '"'.$etag.'"');
}
return $response;
}
/**
* Return a cached file response
*/
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
return response()->file($path, [
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
]);
}
/**
* Serve a brand logo at a specific size
* URL: /images/brand-logo/{brand}/{width?}
@@ -67,8 +100,9 @@ class ImageController extends Controller
if (! $width) {
$contents = Storage::get($brand->logo_path);
$mimeType = Storage::mimeType($brand->logo_path);
$etag = md5($brand->logo_path.$brand->updated_at);
return response($contents)->header('Content-Type', $mimeType);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Map common widths to pre-generated sizes (retina-optimized)
@@ -104,7 +138,7 @@ class ImageController extends Controller
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
return $this->cachedFileResponse($path);
}
/**
@@ -121,8 +155,9 @@ class ImageController extends Controller
if (! $width) {
$contents = Storage::get($brand->banner_path);
$mimeType = Storage::mimeType($brand->banner_path);
$etag = md5($brand->banner_path.$brand->updated_at);
return response($contents)->header('Content-Type', $mimeType);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Map common widths to pre-generated sizes (retina-optimized)
@@ -155,7 +190,7 @@ class ImageController extends Controller
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
return $this->cachedFileResponse($path);
}
/**
@@ -172,8 +207,9 @@ class ImageController extends Controller
if (! $width) {
$contents = Storage::get($product->image_path);
$mimeType = Storage::mimeType($product->image_path);
$etag = md5($product->image_path.$product->updated_at);
return response($contents)->header('Content-Type', $mimeType);
return $this->cachedResponse($contents, $mimeType, $etag);
}
// Check if cached dynamic thumbnail exists in local storage
@@ -202,6 +238,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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}.");
}
}

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,6 +92,12 @@ class CrmThread extends Model
'seller_business_id',
'thread_type',
'order_id',
'quote_id',
// Buyer-side tracking
'is_read_by_buyer',
'read_at_by_buyer',
'buyer_starred_by',
'buyer_archived_by',
];
protected $casts = [
@@ -108,6 +114,11 @@ class CrmThread extends Model
'chat_request_at' => 'datetime',
'chat_request_responded_at' => 'datetime',
'buyer_context' => 'array',
// Buyer-side tracking
'is_read_by_buyer' => 'boolean',
'read_at_by_buyer' => 'datetime',
'buyer_starred_by' => 'array',
'buyer_archived_by' => 'array',
];
protected $appends = ['is_snoozed', 'other_viewers'];
@@ -337,6 +348,62 @@ class CrmThread extends Model
return $query->whereIn('brand_id', $brandIds);
}
// ========================================
// Buyer-side scopes (for buyer inbox/CRM)
// ========================================
/**
* Scope to filter threads for a buyer business.
*/
public function scopeForBuyerBusiness($query, int $businessId)
{
return $query->where('buyer_business_id', $businessId);
}
/**
* Scope to filter threads that have unread messages for buyer.
*/
public function scopeHasUnreadForBuyer($query)
{
return $query->where('is_read', false)
->where('last_message_direction', 'inbound'); // inbound = from seller
}
/**
* Scope to filter threads starred by buyer.
*/
public function scopeStarredByBuyer($query, int $userId)
{
return $query->whereJsonContains('buyer_starred_by', $userId);
}
/**
* Scope to filter threads archived by buyer.
*/
public function scopeArchivedByBuyer($query, int $userId)
{
return $query->whereJsonContains('buyer_archived_by', $userId);
}
/**
* Scope to filter threads NOT archived by buyer.
*/
public function scopeNotArchivedByBuyer($query, int $userId)
{
return $query->where(function ($q) use ($userId) {
$q->whereNull('buyer_archived_by')
->orWhereJsonDoesntContain('buyer_archived_by', $userId);
});
}
/**
* Scope for unread messages from buyer's perspective.
*/
public function scopeUnreadForBuyer($query)
{
return $query->where('is_read_by_buyer', false);
}
// Accessors
public function getIsSnoozedAttribute(): bool
@@ -515,4 +582,84 @@ class CrmThread extends Model
default => 'badge-ghost',
};
}
// ========================================
// Buyer-side helper methods
// ========================================
/**
* Mark thread as read for buyer.
*/
public function markAsReadForBuyer(): void
{
$this->update([
'is_read_by_buyer' => true,
'read_at_by_buyer' => now(),
]);
}
/**
* Toggle star status for buyer.
*/
public function toggleStarForBuyer(int $userId): void
{
$starred = $this->buyer_starred_by ?? [];
if (in_array($userId, $starred)) {
$starred = array_values(array_diff($starred, [$userId]));
} else {
$starred[] = $userId;
}
$this->update(['buyer_starred_by' => $starred]);
}
/**
* Check if thread is starred by buyer.
*/
public function isStarredByBuyer(int $userId): bool
{
return in_array($userId, $this->buyer_starred_by ?? []);
}
/**
* Archive thread for buyer.
*/
public function archiveForBuyer(int $userId): void
{
$archived = $this->buyer_archived_by ?? [];
if (! in_array($userId, $archived)) {
$archived[] = $userId;
}
$this->update(['buyer_archived_by' => $archived]);
}
/**
* Unarchive thread for buyer.
*/
public function unarchiveForBuyer(int $userId): void
{
$archived = $this->buyer_archived_by ?? [];
$archived = array_values(array_diff($archived, [$userId]));
$this->update(['buyer_archived_by' => $archived]);
}
/**
* Get latest message relationship.
*/
public function latestMessage()
{
return $this->hasOne(CrmChannelMessage::class, 'thread_id')->latestOfMany();
}
/**
* Get quote relationship.
*/
public function quote()
{
return $this->belongsTo(CrmQuote::class, 'quote_id');
}
}

View File

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

View File

@@ -31,6 +31,7 @@ class OrderItem extends Model implements Auditable
'product_name',
'product_sku',
'brand_name',
'item_comment',
];
/**

View File

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

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

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

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Collection;
/**
* Service for managing product comparison list.
*
* Stores selected product IDs in the session for side-by-side comparison.
*/
class ProductComparisonService
{
private const SESSION_KEY = 'compare_products';
private const MAX_ITEMS = 4; // Maximum products to compare at once
/**
* Add a product to comparison list.
*/
public function add(int $productId): bool
{
$ids = $this->getProductIds();
if (count($ids) >= self::MAX_ITEMS) {
return false; // List is full
}
if (in_array($productId, $ids)) {
return true; // Already in list
}
$ids[] = $productId;
session()->put(self::SESSION_KEY, $ids);
return true;
}
/**
* Remove a product from comparison list.
*/
public function remove(int $productId): void
{
$ids = $this->getProductIds();
$ids = array_values(array_filter($ids, fn ($id) => $id !== $productId));
session()->put(self::SESSION_KEY, $ids);
}
/**
* Toggle a product in the comparison list.
*
* @return array{added: bool, count: int}
*/
public function toggle(int $productId): array
{
if ($this->isInList($productId)) {
$this->remove($productId);
return ['added' => false, 'count' => $this->count()];
}
$added = $this->add($productId);
return ['added' => $added, 'count' => $this->count()];
}
/**
* Check if a product is in the comparison list.
*/
public function isInList(int $productId): bool
{
return in_array($productId, $this->getProductIds());
}
/**
* Get all product IDs in comparison list.
*/
public function getProductIds(): array
{
return session()->get(self::SESSION_KEY, []);
}
/**
* Get products with full model data for comparison.
*/
public function getProducts(): Collection
{
$ids = $this->getProductIds();
if (empty($ids)) {
return collect();
}
return Product::query()
->with(['brand:id,name,slug', 'strain:id,name,type', 'category:id,name'])
->whereIn('id', $ids)
->where('is_active', true)
->get();
}
/**
* Clear the comparison list.
*/
public function clear(): void
{
session()->forget(self::SESSION_KEY);
}
/**
* Get count of products in comparison list.
*/
public function count(): int
{
return count($this->getProductIds());
}
/**
* Check if list is full.
*/
public function isFull(): bool
{
return $this->count() >= self::MAX_ITEMS;
}
/**
* Get maximum allowed items.
*/
public function maxItems(): int
{
return self::MAX_ITEMS;
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Collection;
/**
* Service for tracking and retrieving recently viewed products.
*
* Stores product IDs in the session with timestamps, limited to the most recent 20 products.
*/
class RecentlyViewedService
{
private const SESSION_KEY = 'recently_viewed_products';
private const MAX_ITEMS = 20;
/**
* Record a product view.
*/
public function recordView(int $productId): void
{
$viewed = session()->get(self::SESSION_KEY, []);
// Remove if already exists (we'll re-add with new timestamp)
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $productId);
// Add to beginning of array
array_unshift($viewed, [
'id' => $productId,
'viewed_at' => now()->timestamp,
]);
// Limit to max items
$viewed = array_slice($viewed, 0, self::MAX_ITEMS);
session()->put(self::SESSION_KEY, $viewed);
}
/**
* Get recently viewed product IDs (most recent first).
*
* @param int|null $limit Limit results (default: all)
* @param int|null $excludeId Exclude a specific product ID
*/
public function getProductIds(?int $limit = null, ?int $excludeId = null): array
{
$viewed = session()->get(self::SESSION_KEY, []);
if ($excludeId) {
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $excludeId);
}
$ids = array_column(array_values($viewed), 'id');
if ($limit) {
$ids = array_slice($ids, 0, $limit);
}
return $ids;
}
/**
* Get recently viewed products with full model data.
*
* @param int $limit Maximum number of products to return
* @param int|null $excludeId Exclude a specific product ID (e.g., current product)
*/
public function getProducts(int $limit = 8, ?int $excludeId = null): Collection
{
$ids = $this->getProductIds($limit + 1, $excludeId); // Get extra to handle filter
if (empty($ids)) {
return collect();
}
// Fetch products in the order they were viewed
$products = Product::query()
->with('brand:id,name,slug')
->whereIn('id', $ids)
->where('is_active', true)
->get();
// Sort by the original order and limit
$idOrder = array_flip($ids);
return $products
->sortBy(fn ($p) => $idOrder[$p->id] ?? PHP_INT_MAX)
->take($limit)
->values();
}
/**
* Clear recently viewed history.
*/
public function clear(): void
{
session()->forget(self::SESSION_KEY);
}
/**
* Get count of recently viewed products.
*/
public function count(): int
{
return count(session()->get(self::SESSION_KEY, []));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('crm_threads', function (Blueprint $table) {
// Buyer-side read tracking (separate from seller-side is_read)
$table->boolean('is_read_by_buyer')->default(true)->after('is_read');
$table->timestamp('read_at_by_buyer')->nullable()->after('is_read_by_buyer');
// Buyer-side star/archive (JSON arrays of user IDs)
$table->jsonb('buyer_starred_by')->nullable()->after('read_at_by_buyer');
$table->jsonb('buyer_archived_by')->nullable()->after('buyer_starred_by');
// Quote relationship
$table->foreignId('quote_id')->nullable()->after('order_id')->constrained('crm_quotes')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('crm_threads', function (Blueprint $table) {
$table->dropConstrainedForeignId('quote_id');
$table->dropColumn([
'is_read_by_buyer',
'read_at_by_buyer',
'buyer_starred_by',
'buyer_archived_by',
]);
});
}
};

View File

@@ -0,0 +1,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');
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,391 @@
@extends('layouts.buyer-app-with-sidebar')
@section('title', 'Buy It Again - ' . config('app.name'))
@section('content')
<div x-data="buyAgain()" x-init="init()">
{{-- Header --}}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">Buy It Again</h1>
<p class="text-base-content/60">Quickly reorder from your favorite brands</p>
</div>
<button class="btn btn-warning gap-2"
@click="addAllToCart()"
:disabled="!hasItemsWithQuantity()"
:class="{ 'btn-disabled': !hasItemsWithQuantity() }">
<span class="icon-[heroicons--shopping-cart] size-5"></span>
Add all to cart
</button>
</div>
{{-- Tabs --}}
<div class="tabs tabs-boxed mb-6 inline-flex">
<a href="{{ route('buyer.business.buy-again', ['business' => $business->slug, 'tab' => 'favorites']) }}"
class="tab {{ $tab === 'favorites' ? 'tab-active' : '' }}">
<span class="icon-[heroicons--heart] size-4 mr-2"></span>
Store favorites
</a>
<a href="{{ route('buyer.business.buy-again', ['business' => $business->slug, 'tab' => 'history']) }}"
class="tab {{ $tab === 'history' ? 'tab-active' : '' }}">
<span class="icon-[heroicons--clock] size-4 mr-2"></span>
Purchase history
</a>
</div>
{{-- Search --}}
<div class="form-control mb-6">
<div class="relative w-full max-w-xs">
<input type="text"
placeholder="Search products..."
class="input input-bordered w-full pl-10"
x-model="search"
@input.debounce.300ms="filterProducts()">
<span class="icon-[heroicons--magnifying-glass] size-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"></span>
</div>
</div>
{{-- Empty State --}}
@if($brands->isEmpty())
<div class="card bg-base-100 shadow-lg">
<div class="card-body items-center text-center py-16">
@if($tab === 'favorites')
<span class="icon-[heroicons--heart] size-16 text-base-content/20 mb-4"></span>
<h3 class="text-xl font-semibold mb-2">No favorite brands yet</h3>
<p class="text-base-content/60 mb-4">Follow brands to see their products here for quick reordering.</p>
<a href="{{ route('buyer.brands.index') }}" class="btn btn-primary">
<span class="icon-[heroicons--building-storefront] size-4"></span>
Browse Brands
</a>
@else
<span class="icon-[heroicons--shopping-bag] size-16 text-base-content/20 mb-4"></span>
<h3 class="text-xl font-semibold mb-2">No purchase history</h3>
<p class="text-base-content/60 mb-4">Place your first order to see your purchase history here.</p>
<a href="{{ route('buyer.browse') }}" class="btn btn-primary">
<span class="icon-[heroicons--shopping-cart] size-4"></span>
Start Shopping
</a>
@endif
</div>
</div>
@else
{{-- Brands (Collapsible) --}}
@foreach($brands as $brand)
<div class="collapse collapse-arrow bg-base-100 border border-base-200 mb-4 shadow-sm"
x-show="brandMatchesSearch('{{ addslashes($brand->name) }}', {{ $brand->products->pluck('name')->map(fn($n) => addslashes($n))->toJson() }})">
<input type="checkbox" checked>
<div class="collapse-title flex items-center gap-3 pe-12">
@if($brand->getLogoUrl('thumb'))
<img src="{{ $brand->getLogoUrl('thumb') }}"
alt="{{ $brand->name }}"
class="w-10 h-10 rounded object-cover bg-base-200">
@else
<div class="w-10 h-10 rounded bg-primary/10 flex items-center justify-center">
<span class="text-primary font-bold">{{ substr($brand->name, 0, 2) }}</span>
</div>
@endif
<span class="font-semibold">{{ $brand->name }}</span>
<span class="badge badge-ghost">{{ $brand->products->count() }} products</span>
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th class="min-w-[250px]">Product</th>
<th>Price</th>
<th>Last Ordered</th>
@if($storeMetrics)
<th>In Stock</th>
<th>Days Left</th>
@endif
<th class="w-36">Quantity</th>
<th class="w-32"></th>
</tr>
</thead>
<tbody>
@foreach($brand->products as $product)
<tr x-show="productMatchesSearch('{{ addslashes($product->name) }}', '{{ addslashes($product->sku ?? '') }}')"
class="hover:bg-base-200/50">
<td>
<div class="flex items-center gap-3">
@if($product->getImageUrl('thumb'))
<img src="{{ $product->getImageUrl('thumb') }}"
alt="{{ $product->name }}"
class="w-12 h-12 rounded object-cover bg-base-200">
@else
<div class="w-12 h-12 rounded bg-base-200 flex items-center justify-center">
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
</div>
@endif
<div>
<div class="font-medium">{{ $product->name }}</div>
@if($product->sku)
<div class="text-xs text-base-content/50 font-mono">
{{ $product->sku }}
</div>
@endif
</div>
</div>
</td>
<td class="font-medium">
${{ number_format($product->wholesale_price ?? $product->price ?? 0, 2) }}
@if($product->unit_type)
<span class="text-xs text-base-content/60">/{{ $product->unit_type }}</span>
@endif
</td>
<td class="text-base-content/70">
@php
$lastOrder = $product->orderItems->first()?->order;
@endphp
{{ $lastOrder?->created_at?->format('M Y') ?? '-' }}
</td>
@if($storeMetrics)
<td>
@if(isset($storeMetrics[$product->id]['on_hand']))
<span class="font-medium">{{ number_format($storeMetrics[$product->id]['on_hand']) }}</span>
@else
<span class="text-base-content/40">-</span>
@endif
</td>
<td>
@if(isset($storeMetrics[$product->id]['days_until_out']))
@php $daysLeft = $storeMetrics[$product->id]['days_until_out']; @endphp
@if($daysLeft < 7)
<span class="badge badge-error badge-sm">
{{ $daysLeft }} days
</span>
@elseif($daysLeft < 14)
<span class="badge badge-warning badge-sm">
{{ $daysLeft }} days
</span>
@else
<span class="text-success">{{ $daysLeft }} days</span>
@endif
@else
<span class="text-base-content/40">-</span>
@endif
</td>
@endif
<td>
<div class="join">
<button class="btn btn-sm join-item"
@click="decrementQty({{ $product->id }})"
:disabled="getQty({{ $product->id }}) <= 0">
<span class="icon-[heroicons--minus] size-4"></span>
</button>
<input type="number"
class="input input-sm input-bordered w-16 join-item text-center"
:value="getQty({{ $product->id }})"
@change="setQty({{ $product->id }}, $event.target.value)"
min="0">
<button class="btn btn-sm join-item"
@click="incrementQty({{ $product->id }})">
<span class="icon-[heroicons--plus] size-4"></span>
</button>
</div>
</td>
<td>
@if($product->is_active && ($product->stock_quantity ?? 1) > 0)
<button class="btn btn-sm btn-primary"
@click="addToCart({{ $product->id }})"
:disabled="getQty({{ $product->id }}) <= 0"
:class="{ 'btn-disabled': getQty({{ $product->id }}) <= 0 }">
<span class="icon-[heroicons--shopping-cart] size-4"></span>
Add
</button>
@else
<span class="badge badge-ghost">Unavailable</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endforeach
@endif
</div>
@endsection
@push('scripts')
<script>
function buyAgain() {
return {
search: '',
quantities: {},
cart: {},
init() {
// Initialize quantities to 1 for all products
@foreach($brands as $brand)
@foreach($brand->products as $product)
this.quantities[{{ $product->id }}] = 1;
@endforeach
@endforeach
this.loadCartState();
},
async loadCartState() {
try {
const response = await fetch('{{ route("buyer.business.cart.index", $business->slug) }}', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
if (data.items && Array.isArray(data.items)) {
data.items.forEach(item => {
this.cart[item.product_id] = {
cartId: item.id,
quantity: item.quantity
};
});
}
}
}
} catch (error) {
console.warn('Error loading cart:', error);
}
},
getQty(productId) {
return this.quantities[productId] || 0;
},
setQty(productId, value) {
this.quantities[productId] = Math.max(0, parseInt(value) || 0);
},
incrementQty(productId) {
this.quantities[productId] = (this.quantities[productId] || 0) + 1;
},
decrementQty(productId) {
if (this.quantities[productId] > 0) {
this.quantities[productId]--;
}
},
hasItemsWithQuantity() {
return Object.values(this.quantities).some(qty => qty > 0);
},
filterProducts() {
// Re-render handled by Alpine x-show directives
},
brandMatchesSearch(brandName, productNames) {
if (!this.search) return true;
const searchLower = this.search.toLowerCase();
if (brandName.toLowerCase().includes(searchLower)) return true;
return productNames.some(name => name.toLowerCase().includes(searchLower));
},
productMatchesSearch(productName, sku) {
if (!this.search) return true;
const searchLower = this.search.toLowerCase();
return productName.toLowerCase().includes(searchLower) ||
(sku && sku.toLowerCase().includes(searchLower));
},
async addToCart(productId) {
const quantity = this.quantities[productId] || 1;
try {
const formData = new FormData();
formData.append('product_id', productId);
formData.append('quantity', quantity);
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
const data = response.data;
if (data.success) {
this.cart[productId] = {
cartId: data.cart_item.id,
quantity: data.cart_item.quantity
};
window.dispatchEvent(new CustomEvent('cart-updated', {
detail: { count: this.getCartCount() }
}));
window.showToast?.('Added to cart', 'success') ||
alert('Added to cart!');
} else {
window.showToast?.(data.message || 'Failed to add to cart', 'error') ||
alert(data.message || 'Failed to add to cart');
}
} catch (error) {
const message = error.response?.data?.message || 'Failed to add product to cart';
window.showToast?.(message, 'error') || alert(message);
}
},
getCartCount() {
return Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0);
},
async addAllToCart() {
const productsToAdd = Object.entries(this.quantities)
.filter(([_, qty]) => qty > 0);
if (productsToAdd.length === 0) {
window.showToast?.('No products selected', 'warning') ||
alert('No products selected');
return;
}
let addedCount = 0;
let failedCount = 0;
for (const [productId, quantity] of productsToAdd) {
try {
const formData = new FormData();
formData.append('product_id', productId);
formData.append('quantity', quantity);
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
if (response.data.success) {
this.cart[productId] = {
cartId: response.data.cart_item.id,
quantity: response.data.cart_item.quantity
};
addedCount++;
} else {
failedCount++;
}
} catch (error) {
failedCount++;
}
}
window.dispatchEvent(new CustomEvent('cart-updated', {
detail: { count: this.getCartCount() }
}));
if (addedCount > 0 && failedCount === 0) {
window.showToast?.(`Added ${addedCount} items to cart`, 'success') ||
alert(`Added ${addedCount} items to cart`);
} else if (addedCount > 0 && failedCount > 0) {
window.showToast?.(`Added ${addedCount} items, ${failedCount} failed`, 'warning') ||
alert(`Added ${addedCount} items, ${failedCount} failed`);
} else {
window.showToast?.('Failed to add items to cart', 'error') ||
alert('Failed to add items to cart');
}
}
}
}
</script>
@endpush

View File

@@ -0,0 +1,361 @@
@extends('layouts.buyer-app-with-sidebar')
@section('title', 'Compare Products - ' . config('app.name'))
@section('content')
<div class="min-h-screen py-6" x-data="compareProducts()">
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold flex items-center gap-2">
<span class="icon-[heroicons--scale] size-7 text-primary"></span>
Compare Products
</h1>
<p class="text-base-content/60 mt-1">Compare up to 4 products side-by-side</p>
</div>
@if($products->count() > 0)
<button @click="clearAll()"
class="btn btn-ghost btn-sm gap-2">
<span class="icon-[heroicons--trash] size-4"></span>
Clear All
</button>
@endif
</div>
@if($products->count() === 0)
{{-- Empty State --}}
<div class="card bg-base-100 shadow-lg">
<div class="card-body text-center py-16">
<span class="icon-[heroicons--scale] size-20 text-base-content/20 mx-auto mb-4"></span>
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No products to compare</h3>
<p class="text-base-content/50 mb-6 max-w-md mx-auto">
Add products to compare by clicking the compare button on product cards while browsing.
</p>
<a href="{{ route('buyer.browse') }}" class="btn btn-primary gap-2">
<span class="icon-[heroicons--shopping-bag] size-5"></span>
Browse Products
</a>
</div>
</div>
@else
{{-- Comparison Table --}}
<div class="overflow-x-auto">
<table class="table bg-base-100 shadow-lg rounded-box">
{{-- Product Images Row --}}
<thead>
<tr>
<th class="w-48 bg-base-200 sticky left-0 z-10">Product</th>
@foreach($products as $product)
<th class="min-w-[250px] text-center p-0">
<div class="relative">
{{-- Remove Button --}}
<button @click="removeProduct({{ $product->id }})"
class="btn btn-xs btn-circle btn-ghost absolute top-2 right-2 z-10 bg-base-100/80 hover:bg-error hover:text-error-content">
<span class="icon-[heroicons--x-mark] size-4"></span>
</button>
{{-- Product Image --}}
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
class="block p-4">
<div class="aspect-square w-full max-w-[200px] mx-auto bg-base-200 rounded-lg overflow-hidden">
@if($product->getImageUrl('medium'))
<img src="{{ $product->getImageUrl('medium') }}"
alt="{{ $product->name }}"
class="w-full h-full object-cover">
@else
<div class="flex items-center justify-center h-full">
<span class="icon-[heroicons--cube] size-16 text-base-content/20"></span>
</div>
@endif
</div>
</a>
</div>
</th>
@endforeach
{{-- Add Product Slot (if under max) --}}
@if($products->count() < 4)
<th class="min-w-[200px] text-center">
<a href="{{ route('buyer.browse') }}"
class="block p-4">
<div class="aspect-square w-full max-w-[180px] mx-auto border-2 border-dashed border-base-300 rounded-lg flex flex-col items-center justify-center hover:border-primary hover:bg-base-200 transition-colors">
<span class="icon-[heroicons--plus] size-10 text-base-content/30"></span>
<span class="text-sm text-base-content/50 mt-2">Add Product</span>
</div>
</a>
</th>
@endif
</tr>
</thead>
<tbody>
{{-- Product Name --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Name</td>
@foreach($products as $product)
<td class="text-center">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
class="font-semibold hover:text-primary">
{{ $product->name }}
</a>
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Brand --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Brand</td>
@foreach($products as $product)
<td class="text-center">
@if($product->brand)
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}"
class="link link-primary">
{{ $product->brand->name }}
</a>
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Price --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Price</td>
@foreach($products as $product)
<td class="text-center">
<span class="text-xl font-bold text-primary">${{ number_format($product->wholesale_price, 2) }}</span>
@if($product->price_unit)
<span class="text-sm text-base-content/60">/ {{ $product->price_unit }}</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- SKU --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">SKU</td>
@foreach($products as $product)
<td class="text-center font-mono text-sm">{{ $product->sku }}</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Stock Status --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Availability</td>
@foreach($products as $product)
<td class="text-center">
@if($product->isInStock())
<span class="badge badge-success gap-1">
<span class="icon-[heroicons--check-circle] size-4"></span>
In Stock
</span>
@else
<span class="badge badge-error gap-1">
<span class="icon-[heroicons--x-circle] size-4"></span>
Out of Stock
</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Category --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Category</td>
@foreach($products as $product)
<td class="text-center">
@if($product->category)
<span class="badge badge-outline">{{ $product->category->name }}</span>
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Strain Type --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Strain Type</td>
@foreach($products as $product)
<td class="text-center">
@if($product->strain)
<span class="badge badge-primary badge-outline">{{ ucfirst($product->strain->type) }}</span>
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- THC % --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">THC Content</td>
@foreach($products as $product)
<td class="text-center">
@if($product->thc_percentage)
<span class="text-lg font-semibold text-primary">{{ $product->thc_percentage }}%</span>
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- CBD % --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">CBD Content</td>
@foreach($products as $product)
<td class="text-center">
@if($product->cbd_percentage)
<span class="text-lg font-semibold text-secondary">{{ $product->cbd_percentage }}%</span>
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Weight --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Net Weight</td>
@foreach($products as $product)
<td class="text-center">
@if($product->net_weight)
{{ $product->net_weight }} {{ $product->weight_unit }}
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Units Per Case --}}
<tr>
<td class="font-semibold bg-base-200 sticky left-0">Units/Case</td>
@foreach($products as $product)
<td class="text-center">
@if($product->units_per_case)
{{ $product->units_per_case }}
@else
<span class="text-base-content/50">-</span>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
{{-- Add to Cart Row --}}
<tr>
<td class="bg-base-200 sticky left-0"></td>
@foreach($products as $product)
<td class="text-center py-4">
@if($product->isInStock())
<button @click="addToCart({{ $product->id }})"
class="btn btn-primary gap-2">
<span class="icon-[heroicons--shopping-cart] size-5"></span>
Add to Cart
</button>
@else
<button disabled class="btn btn-disabled">Out of Stock</button>
@endif
</td>
@endforeach
@if($products->count() < 4)<td></td>@endif
</tr>
</tbody>
</table>
</div>
@endif
</div>
@endsection
@push('scripts')
<script>
function compareProducts() {
return {
async removeProduct(productId) {
try {
const response = await fetch(`/b/compare/remove/${productId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
if (response.ok) {
window.dispatchEvent(new CustomEvent('compare-updated'));
window.location.reload();
}
} catch (error) {
console.error('Failed to remove product:', error);
}
},
async clearAll() {
try {
const response = await fetch('/b/compare/clear', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
if (response.ok) {
window.dispatchEvent(new CustomEvent('compare-updated'));
window.location.reload();
}
} catch (error) {
console.error('Failed to clear comparison:', error);
}
},
async addToCart(productId) {
try {
const formData = new FormData();
formData.append('product_id', productId);
formData.append('quantity', 1);
const business = document.querySelector('[data-business-slug]')?.dataset.businessSlug;
if (!business) throw new Error('Business not found');
const response = await fetch(`/b/${business}/cart/add`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.success) {
window.dispatchEvent(new CustomEvent('cart-updated', {
detail: { count: data.cart_count }
}));
window.showToast?.('Added to cart', 'success');
} else {
throw new Error(data.message || 'Failed to add to cart');
}
} catch (error) {
window.showToast?.(error.message, 'error');
}
}
}
}
</script>
@endpush

View File

@@ -9,7 +9,7 @@
<p class="text-base-content/60">Your conversations with brands</p>
</div>
<div class="flex gap-2">
<a href="{{ route('buyer.crm.inbox.compose') }}" class="btn btn-primary">
<a href="{{ route('buyer.crm.inbox.compose', $business) }}" class="btn btn-primary">
<x-heroicon-o-pencil-square class="w-4 h-4" />
Compose
</a>
@@ -22,7 +22,7 @@
<div class="card bg-base-100 shadow">
<div class="card-body p-4">
<nav class="space-y-1">
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'all']) }}"
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'all']) }}"
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'all' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<span class="flex items-center gap-2">
<x-heroicon-o-inbox class="w-5 h-5" />
@@ -30,7 +30,7 @@
</span>
<span class="badge badge-sm {{ $filter === 'all' ? 'badge-primary-content' : '' }}">{{ $counts['all'] }}</span>
</a>
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'unread']) }}"
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'unread']) }}"
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'unread' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<span class="flex items-center gap-2">
<x-heroicon-o-envelope class="w-5 h-5" />
@@ -40,7 +40,7 @@
<span class="badge badge-primary badge-sm">{{ $counts['unread'] }}</span>
@endif
</a>
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'starred']) }}"
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'starred']) }}"
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'starred' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<span class="flex items-center gap-2">
<x-heroicon-o-star class="w-5 h-5" />
@@ -48,7 +48,7 @@
</span>
<span class="badge badge-sm {{ $filter === 'starred' ? 'badge-primary-content' : '' }}">{{ $counts['starred'] }}</span>
</a>
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'archived']) }}"
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'archived']) }}"
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'archived' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<span class="flex items-center gap-2">
<x-heroicon-o-archive-box class="w-5 h-5" />
@@ -67,7 +67,7 @@
<!-- Search & Actions Bar -->
<div class="border-b border-base-300 p-4">
<div class="flex flex-col sm:flex-row gap-3">
<form class="flex-1" action="{{ route('buyer.crm.inbox.index') }}" method="GET">
<form class="flex-1" action="{{ route('buyer.crm.inbox.index', $business) }}" method="GET">
<input type="hidden" name="filter" value="{{ $filter }}" />
<div class="join w-full">
<input type="text" name="search" value="{{ $search }}"
@@ -79,7 +79,7 @@
</div>
</form>
@if($counts['unread'] > 0)
<form action="{{ route('buyer.crm.inbox.mark-all-read') }}" method="POST">
<form action="{{ route('buyer.crm.inbox.mark-all-read', $business) }}" method="POST">
@csrf
<button type="submit" class="btn btn-ghost btn-sm">
<x-heroicon-o-check class="w-4 h-4" />
@@ -93,7 +93,7 @@
<!-- Threads -->
<div class="divide-y divide-base-200">
@forelse($threads as $thread)
<a href="{{ route('buyer.crm.inbox.show', $thread) }}"
<a href="{{ route('buyer.crm.inbox.show', [$business, $thread]) }}"
class="flex items-start gap-4 p-4 hover:bg-base-200 transition-colors {{ ($thread->unread_count ?? 0) > 0 ? 'bg-primary/5' : '' }}">
<!-- Avatar -->
<div class="avatar placeholder flex-shrink-0">
@@ -150,7 +150,7 @@
@else
<p class="text-lg font-medium">No messages yet</p>
<p class="text-sm mb-4">Start a conversation with a brand</p>
<a href="{{ route('buyer.crm.inbox.compose') }}" class="btn btn-primary">
<a href="{{ route('buyer.crm.inbox.compose', $business) }}" class="btn btn-primary">
<x-heroicon-o-pencil-square class="w-4 h-4" />
Compose Message
</a>

View File

@@ -1,4 +1,8 @@
@extends('layouts.buyer')
@extends('layouts.buyer-app-with-sidebar')
@php
$business = $business ?? request()->route('business');
@endphp
@section('content')
<div class="flex min-h-screen bg-base-200">
@@ -6,19 +10,19 @@
<aside class="w-64 bg-base-100 border-r border-base-300 flex-shrink-0 hidden lg:block">
<div class="p-4 border-b border-base-300">
<h2 class="font-semibold text-lg">CRM Portal</h2>
<p class="text-sm text-base-content/60">{{ auth()->user()->business->name ?? 'Your Business' }}</p>
<p class="text-sm text-base-content/60">{{ $business->name ?? 'Your Business' }}</p>
</div>
<nav class="p-4 space-y-1">
<!-- Dashboard -->
<a href="{{ route('buyer.crm.dashboard') }}"
<a href="{{ route('buyer.crm.dashboard', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.dashboard') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-home class="w-5 h-5" />
<span>Dashboard</span>
</a>
<!-- Inbox -->
<a href="{{ route('buyer.crm.inbox.index') }}"
<a href="{{ route('buyer.crm.inbox.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.inbox.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-inbox class="w-5 h-5" />
<span>Inbox</span>
@@ -28,21 +32,21 @@
</a>
<!-- Orders -->
<a href="{{ route('buyer.crm.orders.index') }}"
<a href="{{ route('buyer.crm.orders.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.orders.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-shopping-bag class="w-5 h-5" />
<span>Orders</span>
</a>
<!-- Quotes -->
<a href="{{ route('buyer.crm.quotes.index') }}"
<a href="{{ route('buyer.crm.quotes.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.quotes.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-document-text class="w-5 h-5" />
<span>Quotes</span>
</a>
<!-- Invoices -->
<a href="{{ route('buyer.crm.invoices.index') }}"
<a href="{{ route('buyer.crm.invoices.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.invoices.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-receipt-percent class="w-5 h-5" />
<span>Invoices</span>
@@ -51,14 +55,14 @@
<div class="divider my-2"></div>
<!-- Brands -->
<a href="{{ route('buyer.crm.brands.index') }}"
<a href="{{ route('buyer.crm.brands.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.brands.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-building-storefront class="w-5 h-5" />
<span>Brands</span>
</a>
<!-- Bookmarks -->
<a href="{{ route('buyer.crm.bookmarks.index') }}"
<a href="{{ route('buyer.crm.bookmarks.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.bookmarks.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-bookmark class="w-5 h-5" />
<span>Bookmarks</span>
@@ -67,14 +71,14 @@
<div class="divider my-2"></div>
<!-- Analytics -->
<a href="{{ route('buyer.crm.analytics.index') }}"
<a href="{{ route('buyer.crm.analytics.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.analytics.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-chart-bar class="w-5 h-5" />
<span>Analytics</span>
</a>
<!-- Tasks -->
<a href="{{ route('buyer.crm.tasks.index') }}"
<a href="{{ route('buyer.crm.tasks.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.tasks.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
<span>Tasks</span>
@@ -84,7 +88,7 @@
</a>
<!-- Team -->
<a href="{{ route('buyer.crm.team.index') }}"
<a href="{{ route('buyer.crm.team.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.team.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-user-group class="w-5 h-5" />
<span>Team</span>
@@ -93,7 +97,7 @@
<div class="divider my-2"></div>
<!-- Settings -->
<a href="{{ route('buyer.crm.settings.index') }}"
<a href="{{ route('buyer.crm.settings.index', $business) }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.settings.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
<x-heroicon-o-cog-6-tooth class="w-5 h-5" />
<span>Settings</span>
@@ -142,50 +146,50 @@
</div>
<nav class="p-4 space-y-1">
<!-- Same nav items as desktop -->
<a href="{{ route('buyer.crm.dashboard') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.dashboard', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-home class="w-5 h-5" />
<span>Dashboard</span>
</a>
<a href="{{ route('buyer.crm.inbox.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.inbox.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-inbox class="w-5 h-5" />
<span>Inbox</span>
</a>
<a href="{{ route('buyer.crm.orders.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.orders.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-shopping-bag class="w-5 h-5" />
<span>Orders</span>
</a>
<a href="{{ route('buyer.crm.quotes.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.quotes.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-document-text class="w-5 h-5" />
<span>Quotes</span>
</a>
<a href="{{ route('buyer.crm.invoices.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.invoices.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-receipt-percent class="w-5 h-5" />
<span>Invoices</span>
</a>
<div class="divider my-2"></div>
<a href="{{ route('buyer.crm.brands.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.brands.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-building-storefront class="w-5 h-5" />
<span>Brands</span>
</a>
<a href="{{ route('buyer.crm.bookmarks.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.bookmarks.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-bookmark class="w-5 h-5" />
<span>Bookmarks</span>
</a>
<div class="divider my-2"></div>
<a href="{{ route('buyer.crm.analytics.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.analytics.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-chart-bar class="w-5 h-5" />
<span>Analytics</span>
</a>
<a href="{{ route('buyer.crm.tasks.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.tasks.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
<span>Tasks</span>
</a>
<a href="{{ route('buyer.crm.team.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.team.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-user-group class="w-5 h-5" />
<span>Team</span>
</a>
<div class="divider my-2"></div>
<a href="{{ route('buyer.crm.settings.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<a href="{{ route('buyer.crm.settings.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
<x-heroicon-o-cog-6-tooth class="w-5 h-5" />
<span>Settings</span>
</a>

View File

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

View File

@@ -1,65 +1,262 @@
@extends('layouts.buyer-app-with-sidebar')
@section('title', 'Brand Directory - ' . config('app.name'))
@section('content')
<div class="container-fluid py-6">
<!-- Page Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">Our Brands</h1>
<p class="text-gray-600 mt-1">Explore our collection of premium cannabis brands</p>
<div class="min-h-screen" x-data="{ viewMode: localStorage.getItem('brand-view') || 'grid' }"
x-init="$watch('viewMode', v => localStorage.setItem('brand-view', v))">
{{-- Hero Section --}}
<div class="bg-gradient-to-br from-primary via-primary to-primary-focus text-primary-content rounded-xl p-8 mb-8 relative overflow-hidden">
<div class="absolute inset-0 opacity-10">
<div class="absolute top-0 right-0 w-1/2 h-full bg-white/20 skew-x-12 transform origin-top-right"></div>
</div>
<div class="relative z-10 max-w-2xl">
<h1 class="text-3xl md:text-4xl font-bold mb-3">Brand Directory</h1>
<p class="text-lg opacity-90 mb-6">
Discover our curated collection of premium cannabis brands. Browse by name or explore featured partners.
</p>
{{-- Search Bar --}}
<form method="GET" action="{{ route('buyer.brands.index') }}" class="flex gap-2">
@if(request('sort'))
<input type="hidden" name="sort" value="{{ request('sort') }}">
@endif
<div class="relative flex-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-primary-content/60">
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
</span>
<input type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search brands..."
class="input w-full pl-10 bg-white/20 border-white/30 placeholder-primary-content/60 text-primary-content focus:bg-white/30">
</div>
<button type="submit" class="btn bg-white/20 border-white/30 hover:bg-white/30 text-primary-content">
Search
</button>
@if(request('search'))
<a href="{{ route('buyer.brands.index', request()->except('search')) }}" class="btn btn-ghost text-primary-content">
Clear
</a>
@endif
</form>
</div>
</div>
<!-- Brands Grid -->
@if($brands->count() > 0)
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@foreach($brands as $brand)
<a href="{{ route('buyer.brands.show', $brand->slug) }}" class="card bg-base-100 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1 duration-300">
<div class="card-body">
<!-- Brand Logo -->
<div class="flex items-center justify-center h-32 mb-4">
@if($brand->logo_path)
<img src="{{ asset($brand->logo_path) }}" alt="{{ $brand->name }}" class="max-h-full max-w-full object-contain">
{{-- Featured Brands (only show when not searching) --}}
@if(!request('search') && $featuredBrands->count() > 0)
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<span class="icon-[heroicons--star] size-6 text-warning"></span>
Featured Brands
</h2>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
@foreach($featuredBrands as $brand)
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
class="card bg-base-100 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1 duration-300 overflow-hidden">
{{-- Banner/Hero Image --}}
<div class="h-24 bg-gradient-to-br from-primary/20 to-secondary/20 relative">
@if($brand->getBannerUrl('medium'))
<img src="{{ $brand->getBannerUrl('medium') }}"
alt="{{ $brand->name }}"
class="w-full h-full object-cover">
@endif
{{-- Logo overlay --}}
<div class="absolute -bottom-8 left-4">
<div class="w-16 h-16 bg-base-100 rounded-xl shadow-lg flex items-center justify-center overflow-hidden border-2 border-base-100">
@if($brand->getLogoUrl('thumb'))
<img src="{{ $brand->getLogoUrl('thumb') }}"
alt="{{ $brand->name }}"
class="w-full h-full object-contain p-1">
@else
<div class="flex items-center justify-center h-full w-full bg-base-200 rounded-lg">
<span class="icon-[heroicons--building-storefront] size-16 text-base-content/30"></span>
</div>
<span class="text-2xl font-bold text-base-content/30">{{ substr($brand->name, 0, 1) }}</span>
@endif
</div>
</div>
</div>
<div class="card-body pt-10 pb-4">
<h3 class="font-bold truncate">{{ $brand->name }}</h3>
<p class="text-sm text-base-content/60">{{ $brand->products_count }} products</p>
</div>
</a>
@endforeach
</div>
</div>
@endif
<!-- Brand Name -->
<h3 class="card-title text-lg justify-center text-center">{{ $brand->name }}</h3>
{{-- Controls Bar --}}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div class="text-sm text-base-content/70">
<span class="font-semibold text-base-content">{{ $brands->count() }}</span> brands
@if(request('search'))
matching "<span class="font-medium">{{ request('search') }}</span>"
@endif
</div>
<!-- Brand Tagline -->
@if($brand->tagline)
<p class="text-sm text-center text-base-content/70 line-clamp-2">{{ $brand->tagline }}</p>
@endif
<div class="flex items-center gap-3">
{{-- Sort --}}
<form method="GET" action="{{ route('buyer.brands.index') }}" class="flex items-center gap-2">
@if(request('search'))
<input type="hidden" name="search" value="{{ request('search') }}">
@endif
<label class="text-sm text-base-content/70">Sort:</label>
<select name="sort" class="select select-bordered select-sm" onchange="this.form.submit()">
<option value="name" {{ request('sort', 'name') === 'name' ? 'selected' : '' }}>Name (A-Z)</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
<option value="products" {{ request('sort') === 'products' ? 'selected' : '' }}>Most Products</option>
<option value="newest" {{ request('sort') === 'newest' ? 'selected' : '' }}>Newest</option>
</select>
</form>
<!-- Stats -->
<div class="divider my-2"></div>
<div class="flex items-center justify-center gap-4 text-sm">
<div class="flex items-center gap-1">
<span class="icon-[heroicons--cube] size-4 text-primary"></span>
<span class="font-medium">{{ $brand->products_count }} {{ Str::plural('product', $brand->products_count) }}</span>
{{-- View Toggle --}}
<div class="btn-group">
<button @click="viewMode = 'grid'"
class="btn btn-sm"
:class="viewMode === 'grid' ? 'btn-active' : ''"
title="Grid view">
<span class="icon-[heroicons--squares-2x2] size-4"></span>
</button>
<button @click="viewMode = 'list'"
class="btn btn-sm"
:class="viewMode === 'list' ? 'btn-active' : ''"
title="List view">
<span class="icon-[heroicons--bars-3] size-4"></span>
</button>
</div>
</div>
</div>
@if($brands->count() > 0)
{{-- Alphabet Quick Jump (only in grid view without search) --}}
@if(!request('search') && $alphabetGroups->count() > 3)
<div class="hidden md:flex flex-wrap gap-1 mb-6" x-show="viewMode === 'grid'">
@foreach($alphabetGroups->keys()->sort() as $letter)
<a href="#brand-{{ $letter }}"
class="btn btn-sm btn-ghost font-mono">{{ $letter }}</a>
@endforeach
</div>
@endif
{{-- Grid View --}}
<div x-show="viewMode === 'grid'" x-cloak>
@if(request('search'))
{{-- Simple grid when searching --}}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
@foreach($brands as $brand)
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
class="card bg-base-100 shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
<div class="card-body p-4 items-center text-center">
<div class="w-20 h-20 flex items-center justify-center mb-2">
@if($brand->getLogoUrl('thumb'))
<img src="{{ $brand->getLogoUrl('thumb') }}"
alt="{{ $brand->name }}"
class="max-w-full max-h-full object-contain">
@else
<div class="w-20 h-20 rounded-xl bg-base-200 flex items-center justify-center">
<span class="text-3xl font-bold text-base-content/20">{{ substr($brand->name, 0, 1) }}</span>
</div>
@endif
</div>
<h3 class="font-semibold text-sm truncate w-full">{{ $brand->name }}</h3>
<span class="text-xs text-base-content/60">{{ $brand->products_count }} products</span>
</div>
</a>
@endforeach
</div>
@else
{{-- Grouped by letter --}}
@foreach($alphabetGroups->sortKeys() as $letter => $letterBrands)
<div id="brand-{{ $letter }}" class="mb-8 scroll-mt-6">
<h3 class="text-2xl font-bold text-primary mb-4 flex items-center gap-3">
<span class="w-10 h-10 rounded-lg bg-primary text-primary-content flex items-center justify-center">{{ $letter }}</span>
<span class="text-base-content/30 text-sm font-normal">{{ $letterBrands->count() }} brands</span>
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
@foreach($letterBrands as $brand)
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
class="card bg-base-100 shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
<div class="card-body p-4 items-center text-center">
<div class="w-20 h-20 flex items-center justify-center mb-2">
@if($brand->getLogoUrl('thumb'))
<img src="{{ $brand->getLogoUrl('thumb') }}"
alt="{{ $brand->name }}"
class="max-w-full max-h-full object-contain">
@else
<div class="w-20 h-20 rounded-xl bg-base-200 flex items-center justify-center">
<span class="text-3xl font-bold text-base-content/20">{{ substr($brand->name, 0, 1) }}</span>
</div>
@endif
</div>
<h3 class="font-semibold text-sm truncate w-full">{{ $brand->name }}</h3>
@if($brand->tagline)
<p class="text-xs text-base-content/50 line-clamp-1">{{ $brand->tagline }}</p>
@endif
<span class="text-xs text-primary font-medium">{{ $brand->products_count }} products</span>
</div>
</a>
@endforeach
</div>
</div>
@endforeach
@endif
</div>
{{-- List View --}}
<div x-show="viewMode === 'list'" x-cloak class="space-y-3">
@foreach($brands as $brand)
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
class="card card-side bg-base-100 shadow hover:shadow-lg transition-shadow overflow-hidden">
{{-- Logo --}}
<figure class="w-24 md:w-32 flex-shrink-0 bg-base-200">
@if($brand->getLogoUrl('thumb'))
<img src="{{ $brand->getLogoUrl('thumb') }}"
alt="{{ $brand->name }}"
class="w-full h-full object-contain p-3">
@else
<div class="flex items-center justify-center h-full">
<span class="text-3xl font-bold text-base-content/20">{{ substr($brand->name, 0, 1) }}</span>
</div>
@endif
</figure>
<!-- CTA -->
<div class="card-actions justify-center mt-4">
<div class="btn btn-primary btn-sm">
<span class="icon-[heroicons--arrow-right] size-4"></span>
Shop Brand
<div class="card-body p-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="flex-1 min-w-0">
<h3 class="font-bold text-lg">{{ $brand->name }}</h3>
@if($brand->tagline)
<p class="text-sm text-base-content/60 line-clamp-1">{{ $brand->tagline }}</p>
@endif
</div>
<div class="flex items-center gap-4 flex-shrink-0">
<div class="text-right">
<div class="text-lg font-bold text-primary">{{ $brand->products_count }}</div>
<div class="text-xs text-base-content/60">products</div>
</div>
<span class="icon-[heroicons--arrow-right] size-5 text-base-content/40"></span>
</div>
</div>
</a>
</div>
</a>
@endforeach
</div>
@else
<!-- Empty State -->
{{-- Empty State --}}
<div class="card bg-base-100 shadow-lg">
<div class="card-body text-center py-16">
<span class="icon-[heroicons--building-storefront] size-16 text-gray-300 mx-auto mb-4"></span>
<h3 class="text-xl font-semibold text-gray-700 mb-2">No Brands Available</h3>
<p class="text-gray-500">Check back soon for our brand collection</p>
<span class="icon-[heroicons--magnifying-glass] size-16 text-base-content/20 mx-auto mb-4"></span>
@if(request('search'))
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No brands found</h3>
<p class="text-base-content/50 mb-4">No brands match "{{ request('search') }}"</p>
<a href="{{ route('buyer.brands.index') }}" class="btn btn-primary btn-sm">
View All Brands
</a>
@else
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No Brands Available</h3>
<p class="text-base-content/50">Check back soon for our brand collection</p>
@endif
</div>
</div>
@endif

View File

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

View File

@@ -368,51 +368,24 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
@foreach($relatedProducts as $relatedProduct)
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
<figure class="relative h-48 bg-gray-100">
@php
$relatedImage = $relatedProduct->images()->where('is_primary', true)->first()
?? $relatedProduct->images()->first();
@endphp
@if($relatedImage)
<img
src="{{ asset('storage/' . $relatedImage->path) }}"
alt="{{ $relatedProduct->name }}"
class="w-full h-full object-cover"
>
@else
<div class="flex items-center justify-center h-full w-full">
<span class="icon-[lucide--image] size-12 text-gray-300"></span>
</div>
@endif
</figure>
<x-marketplace.product-card :product="$relatedProduct" variant="grid" :business="$business" />
@endforeach
</div>
</div>
@endif
<div class="card-body">
<h4 class="card-title text-base">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}"
class="hover:text-primary"
data-track-click="related-product"
data-track-id="{{ $relatedProduct->id }}"
data-track-label="{{ $relatedProduct->name }}">
{{ $relatedProduct->name }}
</a>
</h4>
<div class="text-lg font-bold text-primary">
${{ number_format($relatedProduct->wholesale_price, 2) }}
</div>
<div class="card-actions justify-end mt-2">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}"
class="btn btn-primary btn-sm"
data-track-click="related-product-cta"
data-track-id="{{ $relatedProduct->id }}"
data-track-label="View {{ $relatedProduct->name }}">
View Details
</a>
</div>
</div>
</div>
<!-- Recently Viewed Products -->
@if(isset($recentlyViewed) && $recentlyViewed->count() > 0)
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold flex items-center gap-2">
<span class="icon-[heroicons--clock] size-6 text-base-content/60"></span>
Recently Viewed
</h3>
</div>
<div class="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory scrollbar-hide -mx-2 px-2">
@foreach($recentlyViewed as $viewedProduct)
<x-marketplace.product-card :product="$viewedProduct" variant="compact" :business="$business" />
@endforeach
</div>
</div>

View File

@@ -10,6 +10,8 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@include('partials.pwa')
</head>
<body class="font-sans antialiased">
<script>

View 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

View 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

View 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

View File

@@ -86,9 +86,9 @@
</div>
</div>
<a class="menu-item" href="#">
<span class="icon-[heroicons--megaphone] size-4"></span>
<span class="grow">Promotion</span>
<a class="menu-item {{ request()->routeIs('buyer.deals') ? 'active' : '' }}" href="{{ route('buyer.deals') }}">
<span class="icon-[heroicons--tag] size-4"></span>
<span class="grow">Deals</span>
</a>
<a class="menu-item {{ request()->routeIs('buyer.browse*') ? 'active' : '' }}" href="{{ route('buyer.browse') }}">
@@ -96,6 +96,11 @@
<span class="grow">Shop</span>
</a>
<a class="menu-item {{ request()->routeIs('buyer.business.buy-again') ? 'active' : '' }}" href="{{ route('buyer.business.buy-again', auth()->user()->businesses->first()->slug) }}">
<span class="icon-[heroicons--arrow-path-rounded-square] size-4"></span>
<span class="grow">Buy It Again</span>
</a>
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"

View File

@@ -0,0 +1,120 @@
{{--
Buyer Topbar Account Dropdown
Positioned in the top-right navbar, next to alerts.
Settings is accessible ONLY from this dropdown (not in sidebar).
Structure:
- Profile
- Password
- Settings (Owner/Admin only)
- Sign Out
--}}
@php
$business = auth()->user()?->businesses->first();
$user = auth()->user();
$isOwner = $business && $business->owner_user_id === $user->id;
$isSuperAdmin = $user->user_type === 'admin';
$canManageSettings = $isOwner || $isSuperAdmin;
@endphp
<div x-data="{ accountOpen: false }" class="relative">
{{-- Avatar Button (always visible) --}}
<button
@click="accountOpen = !accountOpen"
class="btn btn-sm btn-circle btn-ghost"
aria-label="Account Menu">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-primary-content text-xs font-semibold">
{{ strtoupper(substr($user->first_name ?? 'U', 0, 1)) }}{{ strtoupper(substr($user->last_name ?? 'S', 0, 1)) }}
</div>
</button>
{{-- Dropdown Menu (opens downward from topbar) --}}
<div x-show="accountOpen"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="accountOpen = false"
class="absolute right-0 top-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-200 z-50 w-56">
{{-- User Info Header --}}
<div class="px-4 py-3 border-b border-base-200">
<div class="font-medium text-sm truncate">
{{ trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')) ?: 'User' }}
</div>
<div class="text-xs text-base-content/60 truncate">{{ $user->email }}</div>
@if($business)
<div class="text-xs text-base-content/50 truncate mt-0.5">{{ $business->name }}</div>
@endif
<div class="mt-1">
@if($isOwner)
<span class="badge badge-primary badge-xs">Owner</span>
@elseif($isSuperAdmin)
<span class="badge badge-error badge-xs">Super Admin</span>
@else
<span class="badge badge-ghost badge-xs">Team Member</span>
@endif
</div>
</div>
<div class="p-2">
<ul class="space-y-0.5">
{{-- Profile --}}
<li>
<a href="{{ $business ? route('buyer.crm.settings.account', $business->slug) : route('buyer.profile') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
<span class="icon-[lucide--user] size-4 text-base-content/50"></span>
<span>Profile</span>
</a>
</li>
{{-- Orders --}}
<li>
<a href="{{ $business ? route('buyer.crm.orders.index', $business->slug) : '#' }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
<span class="icon-[lucide--package] size-4 text-base-content/50"></span>
<span>My Orders</span>
</a>
</li>
{{-- Favorites --}}
<li>
<a href="{{ $business ? route('buyer.crm.bookmarks.index', $business->slug) : '#' }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
<span class="icon-[lucide--heart] size-4 text-base-content/50"></span>
<span>Favorites</span>
</a>
</li>
{{-- Admin Console Section (Owner/Admin Only) --}}
@if($canManageSettings && $business)
<li class="pt-2 mt-2 border-t border-base-200">
<span class="px-3 text-xs font-semibold text-base-content/40 uppercase tracking-wider">Admin Console</span>
</li>
<li>
<a href="{{ route('buyer.crm.settings.index', $business->slug) }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
<span class="icon-[lucide--settings] size-4 text-base-content/50"></span>
<span>Settings</span>
</a>
</li>
@endif
</ul>
{{-- Sign Out --}}
<div class="border-t border-base-200 my-2"></div>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors w-full text-error">
<span class="icon-[lucide--log-out] size-4"></span>
<span>Sign Out</span>
</button>
</form>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,290 @@
{{-- Marketplace Search Autocomplete Component --}}
<div class="relative flex-1 max-w-xl"
x-data="marketplaceSearch()"
@click.away="close()"
@keydown.escape.window="close()">
{{-- Search Input --}}
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
</span>
<input
type="text"
x-model="query"
@input.debounce.300ms="search()"
@focus="onFocus()"
@keydown.down.prevent="moveDown()"
@keydown.up.prevent="moveUp()"
@keydown.enter.prevent="selectCurrent()"
placeholder="Search products, brands..."
class="input input-bordered w-full pl-10 pr-10 h-10"
autocomplete="off"
aria-label="Search products and brands"
aria-haspopup="listbox"
:aria-expanded="isOpen">
{{-- Clear button --}}
<button
x-show="query.length > 0"
x-cloak
@click="clear()"
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/50 hover:text-base-content">
<span class="icon-[heroicons--x-mark] size-5"></span>
</button>
{{-- Loading indicator --}}
<span
x-show="loading"
x-cloak
class="absolute right-3 top-1/2 -translate-y-1/2">
<span class="loading loading-spinner loading-sm text-primary"></span>
</span>
</div>
{{-- Dropdown Results --}}
<div
x-show="isOpen"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-box shadow-xl border border-base-200 z-50 max-h-[70vh] overflow-y-auto"
role="listbox">
{{-- No Query: Show Suggestions --}}
<template x-if="!query && suggestions">
<div class="p-4">
{{-- Popular Searches --}}
<template x-if="suggestions.terms && suggestions.terms.length > 0">
<div class="mb-4">
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider mb-2">Popular Searches</h4>
<div class="flex flex-wrap gap-2">
<template x-for="term in suggestions.terms" :key="term">
<button
@click="searchTerm(term)"
class="badge badge-outline badge-lg hover:badge-primary cursor-pointer transition-colors"
x-text="term">
</button>
</template>
</div>
</div>
</template>
{{-- Trending Products --}}
<template x-if="suggestions.trending && suggestions.trending.length > 0">
<div>
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider mb-2">Trending Products</h4>
<div class="space-y-2">
<template x-for="(product, index) in suggestions.trending" :key="product.id">
<a :href="product.url"
class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-200 transition-colors"
@click="close()">
<div class="w-10 h-10 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden">
<template x-if="product.image_url">
<img :src="product.image_url" :alt="product.name" class="w-full h-full object-cover">
</template>
<template x-if="!product.image_url">
<span class="icon-[heroicons--cube] size-5 text-base-content/30 m-auto"></span>
</template>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate" x-text="product.name"></p>
<p class="text-xs text-base-content/60" x-text="product.brand_name"></p>
</div>
<span class="icon-[heroicons--fire] size-4 text-orange-500"></span>
</a>
</template>
</div>
</div>
</template>
</div>
</template>
{{-- Search Results --}}
<template x-if="query && !loading">
<div>
{{-- No Results --}}
<template x-if="!hasResults">
<div class="p-8 text-center">
<span class="icon-[heroicons--magnifying-glass] size-12 text-base-content/20 mb-3 block mx-auto"></span>
<p class="text-base-content/60">No results found for "<span x-text="query"></span>"</p>
<p class="text-sm text-base-content/40 mt-1">Try a different search term</p>
</div>
</template>
{{-- Brands Section --}}
<template x-if="results.brands && results.brands.length > 0">
<div class="border-b border-base-200">
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider px-4 pt-3 pb-2">Brands</h4>
<div class="pb-2">
<template x-for="(brand, index) in results.brands" :key="brand.id">
<a :href="brand.url"
:class="{'bg-base-200': selectedIndex === index + 'b'}"
class="flex items-center gap-3 px-4 py-2 hover:bg-base-200 transition-colors cursor-pointer"
@mouseenter="selectedIndex = index + 'b'"
@click="close()">
<div class="w-10 h-10 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden flex items-center justify-center">
<template x-if="brand.logo_url">
<img :src="brand.logo_url" :alt="brand.name" class="w-full h-full object-contain p-1">
</template>
<template x-if="!brand.logo_url">
<span class="text-lg font-bold text-base-content/30" x-text="brand.name.charAt(0)"></span>
</template>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" x-text="brand.name"></p>
<p class="text-xs text-base-content/60"><span x-text="brand.products_count"></span> products</p>
</div>
<span class="icon-[heroicons--arrow-right] size-4 text-base-content/40"></span>
</a>
</template>
</div>
</div>
</template>
{{-- Products Section --}}
<template x-if="results.products && results.products.length > 0">
<div>
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider px-4 pt-3 pb-2">Products</h4>
<div class="pb-2">
<template x-for="(product, index) in results.products" :key="product.id">
<a :href="product.url"
:class="{'bg-base-200': selectedIndex === index + 'p'}"
class="flex items-center gap-3 px-4 py-2 hover:bg-base-200 transition-colors cursor-pointer"
@mouseenter="selectedIndex = index + 'p'"
@click="close()">
<div class="w-12 h-12 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden flex items-center justify-center">
<template x-if="product.image_url">
<img :src="product.image_url" :alt="product.name" class="w-full h-full object-cover">
</template>
<template x-if="!product.image_url">
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
</template>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate" x-text="product.name"></p>
<div class="flex items-center gap-2 text-xs text-base-content/60">
<span x-text="product.brand_name"></span>
<span>&bull;</span>
<span x-text="product.sku"></span>
</div>
</div>
<div class="text-right flex-shrink-0">
<p class="text-sm font-semibold text-primary">$<span x-text="parseFloat(product.price).toFixed(2)"></span></p>
</div>
</a>
</template>
</div>
{{-- View All Results Link --}}
<div class="px-4 py-3 border-t border-base-200">
<a :href="'{{ route('buyer.browse') }}?search=' + encodeURIComponent(query)"
class="btn btn-primary btn-sm w-full"
@click="close()">
View all results for "<span x-text="query"></span>"
</a>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<script>
function marketplaceSearch() {
return {
query: '',
isOpen: false,
loading: false,
results: { products: [], brands: [] },
suggestions: null,
selectedIndex: null,
async onFocus() {
if (!this.query) {
await this.loadSuggestions();
}
this.isOpen = true;
},
async loadSuggestions() {
if (this.suggestions) return;
try {
const response = await fetch('{{ route('buyer.search.suggestions') }}');
if (response.ok) {
this.suggestions = await response.json();
}
} catch (error) {
console.error('Failed to load suggestions:', error);
}
},
async search() {
if (this.query.length < 2) {
this.results = { products: [], brands: [] };
if (!this.query) {
await this.loadSuggestions();
}
return;
}
this.loading = true;
this.isOpen = true;
try {
const response = await fetch(`{{ route('buyer.search.autocomplete') }}?q=${encodeURIComponent(this.query)}`);
if (response.ok) {
this.results = await response.json();
}
} catch (error) {
console.error('Search failed:', error);
} finally {
this.loading = false;
}
},
searchTerm(term) {
this.query = term;
this.search();
},
clear() {
this.query = '';
this.results = { products: [], brands: [] };
this.selectedIndex = null;
},
close() {
this.isOpen = false;
this.selectedIndex = null;
},
get hasResults() {
return (this.results.products && this.results.products.length > 0) ||
(this.results.brands && this.results.brands.length > 0);
},
moveDown() {
// Basic keyboard navigation (could be expanded)
this.isOpen = true;
},
moveUp() {
// Basic keyboard navigation
},
selectCurrent() {
if (this.query.length >= 2) {
// Navigate to search results page
window.location.href = '{{ route('buyer.browse') }}?search=' + encodeURIComponent(this.query);
}
}
}
}
</script>

View File

@@ -0,0 +1,100 @@
{{-- Floating Compare Bar - Shows when products are selected for comparison --}}
<div x-data="compareBar()"
x-show="count > 0"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="transform translate-y-full opacity-0"
x-transition:enter-end="transform translate-y-0 opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="transform translate-y-0 opacity-100"
x-transition:leave-end="transform translate-y-full opacity-0"
class="fixed bottom-0 left-0 right-0 z-50 bg-base-100 border-t border-base-300 shadow-lg py-3 px-4"
@compare-updated.window="loadState()">
<div class="container mx-auto flex items-center justify-between gap-4">
{{-- Left side: Count and preview --}}
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--scale] size-5 text-primary"></span>
<span class="font-semibold">
<span x-text="count"></span> / <span x-text="max"></span> products
</span>
</div>
{{-- Product thumbnails --}}
<div class="hidden sm:flex items-center -space-x-2">
<template x-for="id in ids" :key="id">
<div class="w-10 h-10 rounded-lg border-2 border-base-100 bg-base-200 overflow-hidden shadow-sm">
{{-- Product image placeholder - would need to fetch images --}}
<div class="w-full h-full bg-base-300 flex items-center justify-center">
<span class="icon-[heroicons--cube] size-4 text-base-content/30"></span>
</div>
</div>
</template>
</div>
</div>
{{-- Right side: Actions --}}
<div class="flex items-center gap-2">
<button @click="clear()"
class="btn btn-ghost btn-sm">
Clear
</button>
<a href="{{ route('buyer.compare.index') }}"
class="btn btn-primary btn-sm gap-2">
<span class="icon-[heroicons--scale] size-4"></span>
Compare Now
</a>
</div>
</div>
</div>
<script>
function compareBar() {
return {
count: 0,
max: 4,
ids: [],
isFull: false,
init() {
this.loadState();
},
async loadState() {
try {
const response = await fetch('{{ route('buyer.compare.state') }}');
if (response.ok) {
const data = await response.json();
this.count = data.count;
this.max = data.max;
this.ids = data.ids;
this.isFull = data.is_full;
}
} catch (error) {
console.error('Failed to load compare state:', error);
}
},
async clear() {
try {
const response = await fetch('{{ route('buyer.compare.clear') }}', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json'
}
});
if (response.ok) {
this.count = 0;
this.ids = [];
this.isFull = false;
}
} catch (error) {
console.error('Failed to clear comparison:', error);
}
}
}
}
</script>

View File

@@ -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)
&bull; ${{ number_format($product->wholesale_price * $product->units_per_case, 2) }}/case
@endif
</div>
@endif
</div>
{{-- Stock & Add to Cart --}}
@if($showAddToCart)
<div class="card-actions pt-3 border-t border-base-200 mt-2">
@if($product->isInStock())
{{-- 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

View File

@@ -0,0 +1,204 @@
{{-- Quick View Modal Component --}}
<dialog id="quick-view-modal" class="modal"
x-data="quickViewModal()"
@open-quick-view.window="openModal($event.detail)"
@keydown.escape.window="closeModal()">
<div class="modal-box max-w-4xl p-0">
{{-- Loading State --}}
<div x-show="loading" x-cloak class="flex items-center justify-center py-24">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{{-- Product Content --}}
<div x-show="!loading && product" x-cloak>
{{-- Close Button --}}
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 z-10" @click="closeModal()">
<span class="icon-[heroicons--x-mark] size-5"></span>
</button>
</form>
<div class="flex flex-col md:flex-row">
{{-- Product Image --}}
<div class="w-full md:w-2/5 bg-base-200 flex items-center justify-center p-6 min-h-[300px]">
<template x-if="product.image_url">
<img :src="product.image_url"
:alt="product.name"
class="max-h-[300px] max-w-full object-contain">
</template>
<template x-if="!product.image_url">
<span class="icon-[heroicons--cube] size-24 text-base-content/20"></span>
</template>
</div>
{{-- Product Details --}}
<div class="w-full md:w-3/5 p-6">
{{-- Brand --}}
<a :href="product.brand_url"
class="text-sm text-primary font-semibold uppercase tracking-wide hover:underline"
x-text="product.brand_name">
</a>
{{-- Name --}}
<h3 class="text-2xl font-bold mt-1 mb-2" x-text="product.name"></h3>
{{-- SKU --}}
<div class="text-sm text-base-content/60 mb-3">
SKU: <span class="font-mono" x-text="product.sku"></span>
</div>
{{-- THC/CBD Badge --}}
<div class="flex gap-2 mb-4" x-show="product.thc_percentage || product.cbd_percentage">
<template x-if="product.thc_percentage">
<span class="badge badge-primary badge-lg" x-text="'THC ' + product.thc_percentage + '%'"></span>
</template>
<template x-if="product.cbd_percentage">
<span class="badge badge-secondary badge-lg" x-text="'CBD ' + product.cbd_percentage + '%'"></span>
</template>
</div>
{{-- Stock Status --}}
<div class="mb-4">
<template x-if="product.in_stock">
<span class="badge badge-success gap-1">
<span class="icon-[heroicons--check-circle] size-4"></span>
In Stock
</span>
</template>
<template x-if="!product.in_stock">
<span class="badge badge-error gap-1">
<span class="icon-[heroicons--x-circle] size-4"></span>
Out of Stock
</span>
</template>
</div>
{{-- Price --}}
<div class="text-3xl font-bold text-primary mb-4">
$<span x-text="parseFloat(product.price).toFixed(2)"></span>
<span class="text-lg text-base-content/60 font-normal"
x-show="product.price_unit"
x-text="'/ ' + product.price_unit"></span>
</div>
{{-- Description --}}
<div class="text-sm text-base-content/70 mb-6 line-clamp-3" x-text="product.description"></div>
{{-- Actions --}}
<div class="flex flex-col sm:flex-row gap-3">
<template x-if="product.in_stock">
<button class="btn btn-primary flex-1 gap-2"
@click="addToCart()"
:disabled="addingToCart">
<span x-show="!addingToCart">
<span class="icon-[heroicons--shopping-cart] size-5"></span>
Add to Cart
</span>
<span x-show="addingToCart">
<span class="loading loading-spinner loading-sm"></span>
Adding...
</span>
</button>
</template>
<a :href="product.url"
class="btn btn-outline flex-1 gap-2"
@click="closeModal()">
<span class="icon-[heroicons--arrow-right] size-5"></span>
View Full Details
</a>
</div>
</div>
</div>
</div>
{{-- Error State --}}
<div x-show="!loading && error" x-cloak class="p-8 text-center">
<span class="icon-[heroicons--exclamation-triangle] size-16 text-warning mx-auto mb-4"></span>
<h3 class="text-lg font-semibold mb-2">Could not load product</h3>
<p class="text-base-content/60 mb-4" x-text="error"></p>
<button class="btn btn-ghost" @click="closeModal()">Close</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="closeModal()">close</button>
</form>
</dialog>
<script>
function quickViewModal() {
return {
product: null,
loading: false,
error: null,
addingToCart: false,
async openModal(productData) {
this.loading = true;
this.error = null;
this.product = null;
document.getElementById('quick-view-modal').showModal();
try {
// Use hashid if available, otherwise fall back to id
const productKey = productData.hashid || productData.id;
const response = await fetch(`/b/products/${productKey}/quick-view`);
if (!response.ok) throw new Error('Product not found');
this.product = await response.json();
} catch (err) {
this.error = err.message || 'Failed to load product';
} finally {
this.loading = false;
}
},
closeModal() {
document.getElementById('quick-view-modal').close();
this.product = null;
this.error = null;
},
async addToCart() {
if (!this.product || this.addingToCart) return;
this.addingToCart = true;
try {
const formData = new FormData();
formData.append('product_id', this.product.id);
formData.append('quantity', 1);
const business = document.querySelector('[data-business-slug]')?.dataset.businessSlug;
if (!business) throw new Error('Business not found');
const response = await fetch(`/b/${business}/cart/add`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
});
const data = await response.json();
if (response.ok && data.success) {
window.dispatchEvent(new CustomEvent('cart-updated', {
detail: { count: data.cart_count }
}));
window.showToast?.('Added to cart', 'success');
this.closeModal();
} else {
throw new Error(data.message || 'Failed to add to cart');
}
} catch (err) {
window.showToast?.(err.message, 'error');
} finally {
this.addingToCart = false;
}
}
}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@
[x-cloak] { display: none !important; }
</style>
</head>
<body class="font-sans antialiased">
<body class="font-sans antialiased" data-business-slug="{{ auth()->user()?->businesses->first()?->slug }}">
<script>
// DaisyUI theme system
(function() {
@@ -49,29 +49,53 @@
<!-- Topbar -->
<div id="layout-topbar" class="flex items-center justify-between px-6 bg-base-100 border-b border-base-200">
<div class="flex items-center gap-4">
<!-- Single hamburger menu with proper responsive behavior -->
<!-- Mobile hamburger menu - only visible on small screens -->
<label
class="btn btn-square btn-ghost btn-sm"
class="btn btn-square btn-ghost btn-sm lg:hidden"
aria-label="Toggle sidebar"
for="layout-sidebar-toggle-trigger">
<span class="icon-[heroicons--bars-3] size-5"></span>
</label>
<!-- Page heading -->
<h2 class="font-semibold text-xl text-base-content">Dashboard</h2>
{{-- Search Autocomplete (desktop) --}}
<div class="hidden md:block flex-1 max-w-xl">
<x-marketplace-search />
</div>
</div>
<div class="flex items-center gap-4">
<!-- Theme Switcher - Exact Nexus Lucide icons -->
{{-- Mobile Search Button --}}
<button
aria-label="Toggle Theme"
class="btn btn-sm btn-circle btn-ghost relative overflow-hidden"
onclick="toggleTheme()">
<span class="icon-[heroicons--sun] absolute size-4.5 -translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=light]/html:translate-y-0 group-data-[theme=light]/html:opacity-100"></span>
<span class="icon-[heroicons--moon] absolute size-4.5 translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=dark]/html:translate-y-0 group-data-[theme=dark]/html:opacity-100"></span>
<span class="icon-[heroicons--paint-brush] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
class="btn btn-sm btn-circle btn-ghost md:hidden"
aria-label="Search"
onclick="document.getElementById('mobile-search-modal').showModal()">
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
</button>
@php
$buyerBusiness = auth()->user()?->businesses->first();
@endphp
@if($buyerBusiness)
{{-- Chat/Messages --}}
<a href="{{ route('buyer.crm.inbox.index', $buyerBusiness->slug) }}"
class="btn btn-sm btn-circle btn-ghost relative"
aria-label="Messages">
<span class="icon-[heroicons--chat-bubble-left-right] size-5"></span>
@php
$unreadMessageCount = \App\Models\Crm\CrmThread::where('buyer_business_id', $buyerBusiness->id)
->where('is_read', false)
->where('last_message_direction', 'inbound')
->count();
@endphp
@if($unreadMessageCount > 0)
<div class="absolute -top-1 -right-1 bg-success text-success-content text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
<span>{{ $unreadMessageCount > 99 ? '99+' : $unreadMessageCount }}</span>
</div>
@endif
</a>
@endif
<!-- Shopping Cart -->
<div class="relative" x-data="cartCounter()">
<a href="{{ route('buyer.business.cart.index', auth()->user()->businesses->first()->slug) }}" class="btn btn-sm btn-circle btn-ghost relative" aria-label="Shopping Cart">
@@ -87,7 +111,7 @@
</div>
<!-- Notifications - Nexus Basic Style -->
<div class="relative" x-data="notificationDropdown()">
<div class="relative" x-data="notificationDropdown()" x-cloak>
<button class="btn btn-sm btn-circle btn-ghost relative"
aria-label="Notifications"
@click="isOpen = !isOpen; if (isOpen && notifications.length === 0) fetchNotifications();">
@@ -188,7 +212,7 @@
<button class="btn btn-sm btn-ghost" @click="markAllAsRead()">
Mark all as read
</button>
<a href="{{ route('buyer.notifications.index') }}"
<a href="{{ route('buyer.notifications.index') }}"
class="btn btn-sm btn-soft btn-primary"
@click="isOpen = false">
View All
@@ -196,6 +220,19 @@
</div>
</div>
</div>
<!-- Theme Switcher -->
<button
aria-label="Toggle Theme"
class="btn btn-sm btn-circle btn-ghost relative overflow-hidden"
onclick="toggleTheme()">
<span class="icon-[heroicons--sun] absolute size-4.5 -translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=light]/html:translate-y-0 group-data-[theme=light]/html:opacity-100"></span>
<span class="icon-[heroicons--moon] absolute size-4.5 translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=dark]/html:translate-y-0 group-data-[theme=dark]/html:opacity-100"></span>
<span class="icon-[heroicons--paint-brush] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
</button>
<!-- User Account Dropdown (Top Right, next to notifications) -->
<x-buyer-topbar-account />
</div>
</div>
@@ -444,6 +481,29 @@
</script>
{{-- Quick View Modal --}}
<x-marketplace.quick-view-modal />
{{-- Compare Bar (floats at bottom when products selected) --}}
<x-marketplace.compare-bar />
{{-- Mobile Search Modal --}}
<dialog id="mobile-search-modal" class="modal modal-top">
<div class="modal-box max-w-full rounded-t-none p-4">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4">
<span class="icon-[heroicons--x-mark] size-5"></span>
</button>
</form>
<div class="mt-2">
<x-marketplace-search />
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Analytics Tracking -->
@include('partials.analytics')

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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