Compare commits

...

24 Commits

Author SHA1 Message Date
Kelly
44793c23c4 chore: remove debug files and large markdown docs from analytics branch
Remove debug/temporary files that should not be in production:
- check_all_brands.php
- check_cannabrands.php
- check_hash_factory.php
- create_cannabrands_user.php
- fix-models.sh

Remove large AI-generated markdown documentation files:
- 01-analytics-system-complete.md (2273 lines)
- analytics-implementation-guide-REVISED.md (2078 lines)
- QUICK-HANDOFF-CLAUDE-CODE.md (501 lines)
- ARCHITECTURE-DIAGRAMS.md (668 lines)

Note: Analytics routes ARE correctly scoped under {business} prefix
(routes/seller.php line 134-260), contrary to PR #36 code review comment.

Branch is now clean and ready for merge to develop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 20:13:40 -07:00
Kelly
4cf62d92e4 fix: add missing audits variable to ProductController edit methods
Added audits query to both edit() and edit1() methods to prevent
"Undefined variable $audits" error in product edit views.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 08:31:46 -07:00
Kelly
7c0ec86823 fix: add missing analytics detail view files
Added missing view files for analytics detail pages:
- product-detail.blade.php
- buyer-detail.blade.php
- campaign-detail.blade.php

These views are referenced by the analytics controllers but were
not previously created, which could cause 500 errors when accessing
those routes.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 03:09:04 -07:00
Kelly
2c3d12a22c feat(analytics): add Overview menu item to analytics sidebar
Added an "Overview" link to the Analytics menu in the seller sidebar
that routes to the analytics dashboard index page. This provides users
with a clear entry point to view high-level analytics metrics.

The Overview link appears first in the analytics submenu and uses the
'analytics.overview' permission check, consistent with other analytics
menu items.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 02:59:04 -07:00
Kelly
1371d2a59c fix: rename duplicate index names in click_tracking migration
- Change idx_business_element_time to idx_click_business_element_time
- Change idx_business_user_time to idx_click_business_user_time
- Prevents index name conflicts with other tables
2025-11-08 02:49:19 -07:00
Kelly
4dd2e3ae64 Fix code style issues with Laravel Pint
Applied Laravel Pint auto-fixes to resolve all 15 code style issues:
- Fixed unary operator spacing in analytics controllers
- Fixed concat_space in multiple files
- Fixed not_operator_with_successor_space
- Fixed method_chaining_indentation
- Fixed no_unused_imports
- Fixed nullable_type_declaration_for_default_null_value
- Fixed no_superfluous_phpdoc_tags
- Fixed phpdoc formatting
- Fixed braces_position
- Fixed class_attributes_separation
- Fixed ordered_imports
- Fixed single_quote

All changes are cosmetic code style improvements with no functional changes.
2025-11-08 02:43:08 -07:00
Kelly
1649909b73 style: fix Pint code style issues in analytics files
- Fix not_operator_with_successor_space in TrackingController.php
- Remove unused import in AnalyticsTracker.php
2025-11-08 02:32:47 -07:00
Kelly
a5ac7d4217 refactor: rename analytics-tracker.blade.php to analytics.blade.php for clarity 2025-11-08 02:07:41 -07:00
Kelly
eb05a6bcf0 docs: update CLAUDE.md - analytics is now automatic on buyer/guest layouts 2025-11-08 02:06:03 -07:00
Kelly
572c207e39 feat: add automatic analytics tracking to buyer and guest layouts 2025-11-08 02:05:47 -07:00
Kelly
ef5f430e90 docs: add Analytics System usage guide to CLAUDE.md 2025-11-08 02:03:15 -07:00
Kelly
a99a0807d0 Add complete Analytics System with frontend tracking
- Remove global scopes from all analytics models
- Add SMS, Web Push, and Age Verify tracking migrations
- Create self-contained analytics-tracker.blade.php include
- Add TrackingController API endpoints for session/event tracking
- Update all analytics controllers with explicit business scoping
- Update AnalyticsTracker service with forBusiness() pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 01:51:56 -07:00
Kelly
a0194bad9b Add comprehensive analytics implementation documentation
Created ANALYTICS_IMPLEMENTATION.md with complete documentation:

- Overview of all features implemented
- 10 analytics models with descriptions
- Database schema and indexing strategy
- Services, jobs, and real-time features
- 5 controllers and 4 view pages
- Navigation structure and permissions system
- Client-side tracking implementation
- Security test coverage
- Installation and usage instructions
- API endpoints reference
- Helper functions guide
- Git commit summary
- Next steps for future enhancements

Ready for deployment and testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:44:35 -07:00
Kelly
91451893fe Add comprehensive security tests for analytics system
Created AnalyticsSecurityTest with full multi-tenancy coverage:

Tests included:
✓ Analytics events scoped to business
✓ Product views scoped to business
✓ Buyer engagement scores scoped to business
✓ Permission checks (with/without analytics permissions)
✓ Cross-business access prevention
✓ forBusiness scope removes global scope correctly
✓ Unauthenticated access blocked
✓ Auto-set business_id on model creation

All tests verify:
- BusinessScope isolation
- Permission-based access control
- Data cannot leak between businesses
- Automatic business_id assignment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:43:00 -07:00
Kelly
1786c2edb1 Add JavaScript tracking and Reverb real-time listeners
Created client-side analytics tracking:

analytics-tracker.js:
- AnalyticsTracker class for general event tracking
- ProductPageTracker for enhanced product engagement tracking
- Tracks: page views, clicks, scroll depth, time on page
- Product signals: image zoom, video play, spec download, cart/wishlist
- Uses sendBeacon for reliable page exit tracking
- Automatic click tracking via data-track-click attributes

reverb-analytics-listener.js:
- Real-time listener for high-intent buyer events
- Toast notifications for important signals
- Auto-navigation to buyer detail on notification click
- Notification badge updates
- Custom events for UI integration

Both integrate with existing Laravel backend via /api/analytics/track endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:42:00 -07:00
Kelly
9a81a22cc5 Add analytics permissions UI to user management
Added permission management system:
- Updated UserController with updatePermissions method
- Added route for updating user permissions
- Created permissions modal with checkboxes for all analytics permissions
- Added Permissions button to user cards (owner only)
- JavaScript for managing modal state and async permission updates
- Displays current permissions as badges on user cards

Permissions available:
- analytics.overview, products, marketing, sales, buyers, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:40:52 -07:00
Kelly
19bfa889b7 Add analytics views using Nexus/DaisyUI patterns
Created 4 main analytics views:
- dashboard.blade.php: Overview with key metrics, charts, and high-intent signals
- products.blade.php: Product performance, engagement breakdown, view trends
- marketing.blade.php: Email campaign analytics, device/client breakdown
- buyers.blade.php: Buyer intelligence, engagement scores, intent signals

All views use:
- DaisyUI components (cards, tables, badges)
- ApexCharts for data visualization
- Anime.js for counter animations
- Permission checks with hasBusinessPermission()
- Responsive grid layouts
- Period selectors for date filtering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:35:42 -07:00
Kelly
c4bd508241 Update seller sidebar navigation for analytics
Restructured navigation to match requirements:
- Dashboard is now a single top-level item (not a collapse group)
- Analytics is a new parent section with subsections: Products, Marketing, Sales, Buyers
- Reports is a separate future section
- All analytics links use permission checks (hasBusinessPermission)
- Uses proper route names and active state indicators

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:31:57 -07:00
Kelly
b404a533b3 Add analytics models, services, jobs, events, and controllers
Implemented comprehensive analytics system with:
- 6 analytics models (EmailClick, ClickTracking, UserSession, IntentSignal, BuyerEngagementScore + existing 4)
- AnalyticsTracker service for tracking product views, clicks, emails, sessions
- Queue jobs (CalculateEngagementScore, ProcessAnalyticsEvent)
- Reverb event (HighIntentBuyerDetected) for real-time notifications
- 5 analytics controllers (Dashboard, Product, Marketing, Sales, Buyer Intelligence)
- Updated routes with comprehensive analytics routing

All models use BusinessScope for multi-tenancy consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:30:58 -07:00
Kelly
65380b9649 feat(analytics): Add database foundation and helper functions
Phase 1 Complete - Database & Helpers:
- BusinessHelper class with permission checking via business_user.permissions
- Global helper functions (currentBusiness, currentBusinessId, hasBusinessPermission)
- 7 database migrations for analytics tracking
  * analytics_events - Raw event stream
  * product_views - Product engagement tracking
  * email_tracking_tables - Email campaigns, interactions, clicks
  * click_tracking - General click events
  * user_sessions_and_intent - Session tracking + intent signals + buyer engagement scores
  * analytics_permissions - Documentation of available permissions
  * analytics_jobs - Queue table for async processing

Phase 2 In Progress - Models with BusinessScope:
- AnalyticsEvent model
- ProductView model
- EmailCampaign model

All tables use business_id (bigInteger) with composite indexes for multi-tenancy.
All models apply BusinessScope for automatic business isolation.
Helper functions integrated with existing business_user pivot permissions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:07:27 -07:00
Kelly
52facb768e chore: merge latest develop branch with conflict resolution 2025-11-07 00:13:39 -07:00
Kelly
e9230495b4 chore: merge develop - resolve .gitignore conflict 2025-11-07 00:10:25 -07:00
Kelly
06869cf05d feat: add brand management, hashid support, and various improvements 2025-11-07 00:08:20 -07:00
Kelly
555b988c4f chore: ignore nexus build artifacts and generated files
Add gitignore rules for nexus-html build artifacts:
- bootstrap/cache/
- storage/
- public/build/
- database/*.sqlite*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 00:06:26 -07:00
94 changed files with 12211 additions and 1988 deletions

View File

@@ -34,6 +34,7 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
# FILESYSTEM_DISK options: local (development), public (local public), minio (production)
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
@@ -77,25 +78,18 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# AWS/MinIO S3 Storage Configuration
# Local development: Use FILESYSTEM_DISK=public (default)
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_ENDPOINT=
AWS_URL=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production MinIO Configuration (example):
# FILESYSTEM_DISK=s3
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media
# AWS_USE_PATH_STYLE_ENDPOINT=true
# MinIO Configuration (Production Object Storage)
# Set FILESYSTEM_DISK=minio in production
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_REGION=us-east-1
MINIO_BUCKET=cannabrands
MINIO_ENDPOINT=
VITE_APP_NAME="${APP_NAME}"

7
.gitignore vendored
View File

@@ -58,4 +58,11 @@ core.*
!resources/**/*.png
!resources/**/*.jpg
!resources/**/*.jpeg
# Nexus HTML build artifacts and generated files
nexus-html@*/bootstrap/cache/
nexus-html@*/storage/
nexus-html@*/public/build/
nexus-html@*/database/*.sqlite*
.claude/settings.local.json

337
ANALYTICS_IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,337 @@
# Analytics System Implementation - Complete
## Overview
Comprehensive analytics system for Cannabrands B2B marketplace with multi-tenancy security, real-time notifications, and advanced buyer intelligence.
## IMPORTANT: Automatic Tracking for All Buyer Pages
**Analytics tracking is AUTOMATIC and requires NO CODE in individual views.**
- Tracking is included in the buyer layout file: `layouts/buyer-app-with-sidebar.blade.php`
- Any page that extends this layout automatically gets tracking (page views, scroll depth, time on page, sessions)
- When creating new buyer pages, simply extend the layout - tracking is automatic
- NO need to add analytics code to individual views
```blade
{{-- Example: Any new buyer page --}}
@extends('layouts.buyer-app-with-sidebar')
@section('content')
{{-- Your content here - tracking happens automatically --}}
@endsection
```
**Optional:** Add `data-track-click` attributes to specific elements for granular click tracking, but basic analytics work without any additional code.
## Features Implemented
### 1. Multi-Tenancy Security
- **BusinessScope**: All analytics models use global scope for automatic data isolation
- **Auto-scoping**: business_id automatically set on model creation
- **Permission System**: Granular analytics permissions via business_user.permissions JSON
- **Cross-Business Protection**: Users cannot access other businesses' analytics data
### 2. Analytics Models (10 Total)
All models in `app/Models/Analytics/`:
1. **AnalyticsEvent** - Raw event stream (all interactions)
2. **ProductView** - Product engagement tracking
3. **EmailCampaign** - Email campaign management
4. **EmailInteraction** - Individual recipient tracking
5. **EmailClick** - Email link click tracking
6. **ClickTracking** - General click event tracking
7. **UserSession** - Session tracking and conversion funnel
8. **IntentSignal** - High-intent buyer detection
9. **BuyerEngagementScore** - Calculated engagement metrics
10. **EmailCampaign** - (already mentioned above)
### 3. Database Migrations (7 Tables)
All in `database/migrations/2025_11_08_*`:
- `analytics_events` - Event stream
- `product_views` - Product engagement
- `email_campaigns`, `email_interactions`, `email_clicks` - Email analytics
- `click_tracking` - Click events
- `user_sessions`, `intent_signals`, `buyer_engagement_scores` - Buyer intelligence
**Important**: All composite indexes start with `business_id` for query performance.
### 4. Services & Jobs
**AnalyticsTracker Service** (`app/Services/AnalyticsTracker.php`):
- `trackProductView()` - Track product page views with signals
- `trackClick()` - Track general click events
- `trackEmailInteraction()` - Track email actions
- `startSession()` - Initialize/update user sessions
- `detectIntentSignals()` - Automatic high-intent detection
**Queue Jobs**:
- `CalculateEngagementScore` - Compute buyer engagement scores
- `ProcessAnalyticsEvent` - Async event processing
### 5. Real-Time Features
**Reverb Event** (`app/Events/HighIntentBuyerDetected.php`):
- Broadcasts when high-intent signals detected
- Channel: `business.{id}.analytics`
- Event: `high-intent-buyer-detected`
### 6. Controllers (5 Total)
All in `app/Http/Controllers/Analytics/`:
1. **AnalyticsDashboardController** - Overview dashboard
2. **ProductAnalyticsController** - Product performance
3. **MarketingAnalyticsController** - Email campaigns
4. **SalesAnalyticsController** - Sales funnel
5. **BuyerIntelligenceController** - Buyer engagement
### 7. Views (4 Main Pages)
All in `resources/views/seller/analytics/`:
- `dashboard.blade.php` - Analytics overview
- `products.blade.php` - Product analytics
- `marketing.blade.php` - Marketing analytics
- `sales.blade.php` - Sales analytics
- `buyers.blade.php` - Buyer intelligence
**Design**:
- DaisyUI/Nexus components
- ApexCharts for visualizations
- Anime.js for counter animations
- Responsive grid layouts
### 8. Navigation
Updated `resources/views/components/seller-sidebar.blade.php`:
- Dashboard - Single top-level item
- Analytics - Parent with subsections (Products, Marketing, Sales, Buyers)
- Reports - Separate future section
- Permission-based visibility
### 9. Permissions System
**Available Permissions**:
- `analytics.overview` - Main dashboard
- `analytics.products` - Product analytics
- `analytics.marketing` - Marketing analytics
- `analytics.sales` - Sales analytics
- `analytics.buyers` - Buyer intelligence
- `analytics.export` - Data export
**Permission UI** (`resources/views/business/users/index.blade.php`):
- Modal for managing user permissions
- Available to business owners only
- Real-time updates via AJAX
### 10. Client-Side Tracking
**analytics-tracker.js**:
- Automatic page view tracking
- Scroll depth tracking
- Time on page tracking
- Click tracking via `data-track-click` attributes
- ProductPageTracker for enhanced product tracking
**reverb-analytics-listener.js**:
- Real-time high-intent buyer notifications
- Toast notifications
- Auto-navigation to buyer details
- Notification badge updates
### 11. Security Tests
Comprehensive test suite in `tests/Feature/Analytics/AnalyticsSecurityTest.php`:
- ✓ Data scoped to business
- ✓ Permission enforcement
- ✓ Cross-business access prevention
- ✓ Auto business_id assignment
- ✓ forBusiness scope functionality
## Installation Steps
### 1. Run Composer Autoload
```bash
docker compose exec laravel.test composer dump-autoload
```
### 2. Run Migrations
```bash
docker compose exec laravel.test php artisan migrate
```
### 3. Include JavaScript Files
Add to your layout file:
```html
<script src="{{ asset('js/analytics-tracker.js') }}"></script>
<script src="{{ asset('js/reverb-analytics-listener.js') }}"></script>
<!-- Add business ID meta tag -->
<meta name="business-id" content="{{ currentBusinessId() }}">
```
### 4. Queue Configuration
Ensure Redis queues are configured in `.env`:
```env
QUEUE_CONNECTION=redis
```
Start queue worker:
```bash
docker compose exec laravel.test php artisan queue:work --queue=analytics
```
### 5. Reverb Configuration
Reverb should already be configured. Verify broadcasting is enabled:
```env
BROADCAST_DRIVER=reverb
```
## Usage
### Assigning Analytics Permissions
1. Navigate to Business > Users
2. Click "Permissions" button on user card (owner only)
3. Select analytics permissions
4. Save
### Tracking Product Views (Server-Side)
```php
use App\Services\AnalyticsTracker;
$tracker = new AnalyticsTracker();
$tracker->trackProductView($product, [
'zoomed_image' => true,
'time_on_page' => 120,
'added_to_cart' => true
]);
```
### Tracking Product Views (Client-Side)
```html
<!-- Product page -->
<script>
const productTracker = new ProductPageTracker({{ $product->id }});
</script>
<!-- Mark trackable elements -->
<button data-product-add-cart>Add to Cart</button>
<a data-product-spec-download href="/spec.pdf">Download Spec</a>
<div data-product-image-zoom>
<img src="/product.jpg">
</div>
```
### Tracking Clicks
```html
<button data-track-click="cta_button"
data-track-id="123"
data-track-label="Buy Now">
Buy Now
</button>
```
### Listening to Real-Time Events
```javascript
window.addEventListener('analytics:high-intent-buyer', (event) => {
console.log('High intent buyer:', event.detail);
// Update UI, show notification, etc.
});
```
### Calculating Engagement Scores
```php
use App\Jobs\CalculateEngagementScore;
CalculateEngagementScore::dispatch($sellerBusinessId, $buyerBusinessId)
->onQueue('analytics');
```
## API Endpoints
### Analytics Routes
All routes prefixed with `/s/{business}/analytics`:
- `GET /` - Analytics dashboard
- `GET /products` - Product analytics
- `GET /products/{product}` - Product detail
- `GET /marketing` - Marketing analytics
- `GET /marketing/campaigns/{campaign}` - Campaign detail
- `GET /sales` - Sales analytics
- `GET /buyers` - Buyer intelligence
- `GET /buyers/{buyer}` - Buyer detail
### Permission Management
- `POST /s/{business}/users/{user}/permissions` - Update user permissions
## Database Indexes
All analytics tables have composite indexes starting with `business_id`:
- `idx_business_created` - (business_id, created_at)
- `idx_business_type_created` - (business_id, event_type, created_at)
- `idx_business_product_time` - (business_id, product_id, viewed_at)
This ensures optimal query performance for multi-tenant queries.
## Helper Functions
Global helpers available everywhere:
```php
// Get current business
$business = currentBusiness();
// Get current business ID
$businessId = currentBusinessId();
// Check permission
if (hasBusinessPermission('analytics.overview')) {
// User has permission
}
// Get business from product
$sellerBusiness = \App\Helpers\BusinessHelper::fromProduct($product);
```
## Git Commits
Total of 6 commits:
1. **Foundation** - Helpers, migrations, base models
2. **Backend Logic** - Remaining models, services, jobs, events, controllers, routes
3. **Navigation** - Updated seller sidebar
4. **Views** - 4 analytics dashboards
5. **Permissions** - User permission management UI
6. **JavaScript** - Client-side tracking and Reverb listeners
7. **Tests** - Security test suite
## Testing
Run analytics security tests:
```bash
docker compose exec laravel.test php artisan test tests/Feature/Analytics/AnalyticsSecurityTest.php
```
## Notes
- All analytics data is scoped to business_id automatically via BusinessScope
- Permission checks use `hasBusinessPermission()` helper
- High-intent signals trigger real-time Reverb events
- Engagement scores calculated asynchronously via queue
- Client-side tracking uses sendBeacon for reliability
- All views use DaisyUI components (no inline styles)
## Next Steps (Optional Enhancements)
1. Add more granular permissions (view vs export)
2. Implement scheduled engagement score recalculation
3. Add email templates for high-intent buyer alerts
4. Create analytics export functionality (CSV/PDF)
5. Add custom date range selectors
6. Implement analytics API for third-party integrations
7. Add more chart types and visualizations
8. Create analytics widgets for main dashboard
## Support
For issues or questions:
- Check migration files for table structure
- Review controller methods for query patterns
- Examine test file for usage examples
- Check JavaScript console for client-side errors

196
ANALYTICS_QUICK_START.md Normal file
View File

@@ -0,0 +1,196 @@
# Analytics System - Quick Start Guide
## ✅ What's Already Done
### 1. Global Tracking (Automatic)
- Analytics tracker loaded on every page via `app-with-sidebar.blade.php`
- Automatically tracks:
- Page views
- Time spent on page
- Scroll depth
- User sessions
- All clicks with `data-track-click` attributes
### 2. Product Page Tracking (Implemented!)
- **File**: `buyer/marketplace/product.blade.php`
- **Tracks**:
- Product views (automatic on page load)
- Image zoom clicks (gallery images)
- Lab report downloads
- Add to cart button clicks
- Related product clicks
### 3. Real-Time Notifications (For Sellers)
- Reverb integration for high-intent buyer alerts
- Automatically enabled for sellers
- Toast notifications appear when buyers show buying signals
## 🚀 How to See It Working
### Step 1: Visit a Product Page
```
http://yoursite.com/b/{business}/brands/{brand}/products/{product}
```
### Step 2: Open Browser DevTools
- Press `F12`
- Go to **Network** tab
- Filter by: `track`
### Step 3: Interact with the Page
- Scroll down
- Click gallery images
- Click "Add to Cart"
- Click "View Report" on lab results
- Click related products
### Step 4: Check Network Requests
Look for POST requests to `/api/analytics/track` with payloads like:
```json
{
"event_type": "product_view",
"product_id": 123,
"session_id": "abc-123-def",
"timestamp": 1699123456789
}
```
### Step 5: View Analytics Dashboard
```
http://yoursite.com/s/{business-slug}/analytics
```
## 📊 What Data You'll See
### Product Analytics
Navigate to: **Analytics > Products**
View:
- Most viewed products
- Product engagement rates
- Image zoom rates
- Lab report download counts
- Add to cart conversion rates
### Buyer Intelligence
Navigate to: **Analytics > Buyers**
View:
- Active buyers this week
- High-intent buyers (with real-time alerts)
- Engagement scores
- Buyer activity timelines
### Click Heatmap Data
- All clicks tracked with element type, ID, and label
- Position tracking for understanding UX
- Referrer and UTM campaign tracking
## 🎯 Next Steps
### 1. Add Tracking to More Pages
**Marketplace/Catalog Pages:**
```blade
<div class="product-card">
<a href="{{ route('products.show', $product) }}"
data-track-click="product-card"
data-track-id="{{ $product->id }}"
data-track-label="{{ $product->name }}">
{{ $product->name }}
</a>
</div>
```
**Navigation Links:**
```blade
<a href="{{ route('brands.index') }}"
data-track-click="navigation"
data-track-label="Browse Brands">
Browse All Brands
</a>
```
**Filter/Sort Controls:**
```blade
<select data-track-click="filter"
data-track-id="category-filter"
data-track-label="Product Category">
<option>All Categories</option>
</select>
```
### 2. Grant Analytics Permissions
1. Go to: `Settings > Users`
2. Click "Permissions" on a user
3. Check analytics permissions:
- ✅ `analytics.overview` - Dashboard
- ✅ `analytics.products` - Product stats
- ✅ `analytics.buyers` - Buyer intelligence
- ✅ `analytics.marketing` - Email campaigns
- ✅ `analytics.sales` - Sales pipeline
- ✅ `analytics.export` - Export data
### 3. Test Real-Time Notifications (Sellers)
**Trigger Conditions:**
- View same product 3+ times
- View 5+ products from same brand
- Download lab reports
- Add items to cart
- Watch product videos (when implemented)
**Where Alerts Appear:**
- Toast notification (top-right)
- Bell icon badge (topbar)
- Buyer Intelligence page
## 🔧 Troubleshooting
### Not Seeing Tracking Requests?
1. Check browser console for errors
2. Verify `/js/analytics-tracker.js` loads correctly
3. Check CSRF token is present in page meta tags
### Analytics Dashboard Empty?
1. Ensure you've visited product pages as a buyer
2. Check database: `select * from analytics_events limit 10;`
3. Verify background jobs are running: `php artisan queue:work`
### No Real-Time Notifications?
1. Ensure Reverb server is running: `php artisan reverb:start`
2. Check business ID meta tag in page source
3. Verify Laravel Echo is initialized
## 📁 Key Files
### JavaScript
- `/public/js/analytics-tracker.js` - Main tracker
- `/public/js/reverb-analytics-listener.js` - Real-time listener
### Blade Templates
- `layouts/app-with-sidebar.blade.php` - Global setup
- `buyer/marketplace/product.blade.php` - Example implementation
### Controllers
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
### Models
- `app/Models/Analytics/AnalyticsEvent.php`
- `app/Models/Analytics/ProductView.php`
- `app/Models/Analytics/ClickTracking.php`
- `app/Models/Analytics/BuyerEngagementScore.php`
## 📚 Documentation
- **Full Implementation Guide**: `ANALYTICS_IMPLEMENTATION.md`
- **Tracking Examples**: `ANALYTICS_TRACKING_EXAMPLES.md`
- **This Quick Start**: `ANALYTICS_QUICK_START.md`
## 🎉 You're Ready!
The analytics system is now live and collecting data. Start browsing product pages and watch the data flow into your analytics dashboard!

View File

@@ -0,0 +1,216 @@
# Analytics Tracking Implementation Examples
This guide shows you how to implement analytics tracking on your pages to start collecting data.
## 1. Auto-Tracking (Already Working!)
The analytics tracker is now automatically loaded on all pages via `app-with-sidebar.blade.php`. It already tracks:
- Page views
- Time on page
- Scroll depth
- Session management
- Any element with `data-track-click` attribute
## 2. Product Detail Page Tracking
Add this script to the **bottom** of your product blade file (`buyer/marketplace/product.blade.php`):
```blade
@push('scripts')
<script>
// Initialize product page tracker
const productTracker = new ProductPageTracker({{ $product->id }});
// The tracker automatically tracks:
// - Product views (done on initialization)
// - Image zoom clicks
// - Video plays
// - Spec downloads
// - Add to cart
// - Add to wishlist
</script>
@endpush
```
### Add Tracking Attributes to Product Page Elements
Update your product page HTML to include tracking attributes:
```blade
<!-- Image Gallery (for zoom tracking) -->
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-75"
data-product-image-zoom>
<img src="{{ asset('storage/' . $image->path) }}" alt="{{ $product->name }}">
</div>
<!-- Product Videos (for video tracking) -->
<video controls data-product-video>
<source src="{{ asset('storage/' . $video->path) }}" type="video/mp4">
</video>
<!-- Download Spec Sheet Button -->
<a href="{{ route('buyer.products.spec-download', $product) }}"
data-product-spec-download
class="btn btn-outline">
<span class="icon-[lucide--download] size-4"></span>
Download Spec Sheet
</a>
<!-- Add to Cart Button -->
<button type="submit"
data-product-add-cart
class="btn btn-primary btn-block">
<span class="icon-[lucide--shopping-cart] size-5"></span>
Add to Cart
</button>
<!-- Add to Wishlist Button -->
<button type="button"
data-product-add-wishlist
class="btn btn-ghost">
<span class="icon-[lucide--heart] size-5"></span>
Save for Later
</button>
```
## 3. Generic Click Tracking
Track any button or link by adding the `data-track-click` attribute:
```blade
<!-- Track navigation clicks -->
<a href="{{ route('buyer.brands.index') }}"
data-track-click="navigation"
data-track-id="brands-link"
data-track-label="View All Brands">
Browse Brands
</a>
<!-- Track CTA buttons -->
<button data-track-click="cta"
data-track-id="contact-seller"
data-track-label="Contact Seller Button">
Contact Seller
</button>
<!-- Track filters -->
<select data-track-click="filter"
data-track-id="category-filter"
data-track-label="Category Filter">
<option>All Categories</option>
</select>
```
### Click Tracking Attributes
- `data-track-click="type"` - Element type (required): `navigation`, `cta`, `filter`, `button`, etc.
- `data-track-id="unique-id"` - Unique identifier for this element (optional)
- `data-track-label="Label"` - Human-readable label (optional, defaults to element text)
- `data-track-url="url"` - Destination URL (optional, auto-detected for links)
## 4. Dashboard/Catalog Page Tracking
For product listings and catalogs, add click tracking to product cards:
```blade
@foreach($products as $product)
<div class="card">
<!-- Track product card clicks -->
<a href="{{ route('buyer.products.show', $product) }}"
data-track-click="product-card"
data-track-id="{{ $product->id }}"
data-track-label="{{ $product->name }}"
class="card-body">
<img src="{{ $product->image_url }}" alt="{{ $product->name }}">
<h3>{{ $product->name }}</h3>
<p>${{ $product->wholesale_price }}</p>
</a>
<!-- Track quick actions -->
<button data-track-click="quick-add"
data-track-id="{{ $product->id }}"
data-track-label="Quick Add - {{ $product->name }}"
class="btn btn-sm">
Quick Add
</button>
</div>
@endforeach
```
## 5. Viewing Analytics Data
Once tracking is implemented, data will flow to:
1. **Analytics Dashboard**: `https://yoursite.com/s/{business-slug}/analytics`
2. **Product Analytics**: `https://yoursite.com/s/{business-slug}/analytics/products`
3. **Buyer Intelligence**: `https://yoursite.com/s/{business-slug}/analytics/buyers`
## 6. Backend API Endpoint
The tracker sends events to: `/api/analytics/track`
This endpoint is already set up and accepts:
```json
{
"event_type": "product_view|click|product_signal",
"product_id": 123,
"element_type": "button",
"element_id": "add-to-cart",
"element_label": "Add to Cart",
"session_id": "uuid",
"timestamp": 1234567890
}
```
## 7. Real-Time Notifications (Sellers Only)
For sellers with Reverb enabled, high-intent buyer alerts will automatically appear when:
- A buyer views multiple products from your brand
- A buyer repeatedly views the same product
- A buyer downloads spec sheets
- A buyer adds items to cart
- A buyer watches product videos
Notifications appear as toast messages and in the notification dropdown.
## 8. Permission Setup
To grant users access to analytics:
1. Go to **Users** page in your business dashboard
2. Click "Permissions" on a user card
3. Check the analytics permissions you want to grant:
- `analytics.overview` - Dashboard access
- `analytics.products` - Product performance
- `analytics.marketing` - Email campaigns
- `analytics.sales` - Sales intelligence
- `analytics.buyers` - Buyer insights
- `analytics.export` - Export data
## Quick Start Checklist
- [x] Analytics scripts loaded in layout ✅ (Done automatically)
- [ ] Add product page tracker to product detail pages
- [ ] Add tracking attributes to product images/videos/buttons
- [ ] Add click tracking to navigation and CTAs
- [ ] Add tracking to product cards in listings
- [ ] Grant analytics permissions to team members
- [ ] Visit analytics dashboard to see data
## Testing
To test if tracking is working:
1. Open browser DevTools (F12)
2. Go to Network tab
3. Navigate to a product page
4. Look for POST requests to `/api/analytics/track`
5. Check the payload to see what data is being sent
## Need Help?
Check `ANALYTICS_IMPLEMENTATION.md` for full technical documentation.

View File

@@ -121,3 +121,69 @@ Product::where('is_active', true)->get(); // No business_id filter!
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing
---
## Analytics System
### How It Works
Analytics tracking is **AUTOMATIC** on all buyer and public pages:
- `layouts/buyer-app-with-sidebar.blade.php` - All authenticated buyer pages
- `layouts/guest.blade.php` - All public/guest pages (registration, etc.)
**For product pages, pass the product to enable engagement tracking:**
```blade
@include('partials.analytics', ['product' => $product])
```
**For custom layouts, manually include:**
```blade
@include('partials.analytics')
```
### What Gets Tracked Automatically
Once included, the tracker automatically captures:
- Page views and time on page
- Scroll depth
- Session data
- Elements with `data-track-click` attribute
### Product Engagement Signals
On product pages, also tracks:
- Image zoom: `data-action="zoom-image"`
- Video views: `data-action="watch-video"`
- Spec downloads: `data-action="download-spec"`
- Add to cart: `data-action="add-to-cart"`
- Add to wishlist: `data-action="add-to-wishlist"`
### Adding Click Tracking
```blade
<button data-track-click
data-track-type="button"
data-track-id="cta-button"
data-track-label="Request Quote">
Request Quote
</button>
```
### Analytics Dashboard Routes
- `/s/{business}/analytics` - Overview dashboard
- `/s/{business}/analytics/products` - Product analytics
- `/s/{business}/analytics/buyers` - Buyer intelligence
- `/s/{business}/analytics/marketing` - Email campaigns
- `/s/{business}/analytics/sales` - Sales pipeline
### Key Files
- **Tracker**: `resources/views/partials/analytics.blade.php`
- **Controllers**: `app/Http/Controllers/Analytics/*`
- **Models**: `app/Models/Analytics/*`
- **Service**: `app/Services/AnalyticsTracker.php`
### Business Scoping
All analytics queries use explicit `forBusiness($businessId)` scoping:
```php
ProductView::forBusiness($business->id)->where(...)->get();
BuyerEngagementScore::forBusiness($business->id)->highValue()->get();
```
**See**: `ANALYTICS_QUICK_START.md` for detailed implementation examples

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Events;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HighIntentBuyerDetected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $sellerBusinessId,
public int $buyerBusinessId,
public IntentSignal $signal,
public ?BuyerEngagementScore $engagementScore = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
return new Channel("business.{$this->sellerBusinessId}.analytics");
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'buyer_business_id' => $this->buyerBusinessId,
'buyer_business_name' => $this->signal->buyerBusiness?->name,
'signal_type' => $this->signal->signal_type,
'signal_strength' => $this->signal->signal_strength,
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
'total_engagement_score' => $this->engagementScore?->total_score,
'detected_at' => $this->signal->detected_at->toIso8601String(),
'context' => $this->signal->context,
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'high-intent-buyer-detected';
}
}

View File

@@ -85,6 +85,22 @@ class UserResource extends Resource
'suspended' => 'Suspended',
])
->default('active'),
TextInput::make('password')
->label('Password')
->password()
->required(fn ($record) => $record === null)
->dehydrated(fn ($state) => filled($state))
->minLength(8)
->maxLength(255)
->helperText('Leave blank to keep current password when editing')
->visible(fn ($livewire) => $livewire instanceof CreateUser),
TextInput::make('password_confirmation')
->label('Confirm Password')
->password()
->required(fn ($record) => $record === null && filled($record?->password))
->dehydrated(false)
->same('password')
->visible(fn ($livewire) => $livewire instanceof CreateUser),
])->columns(2),
Section::make('Business Association')

View File

@@ -4,8 +4,18 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Hash;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $data;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Helpers;
use App\Models\Business;
use Illuminate\Support\Facades\Auth;
class BusinessHelper
{
/**
* Get the current business for the authenticated user
*/
public static function current(): ?Business
{
if (! Auth::check()) {
return null;
}
return Auth::user()->primaryBusiness();
}
/**
* Get current business ID
*/
public static function currentId(): ?int
{
$business = self::current();
return $business?->id;
}
/**
* Check if user has permission for current business
*/
public static function hasPermission(string $permission): bool
{
if (! Auth::check()) {
return false;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return false;
}
// Super admin has all permissions
if ($user->user_type === 'admin') {
return true;
}
// Get permissions from business_user pivot
$businessUser = $user->businesses()
->where('businesses.id', $business->id)
->first();
if (! $businessUser) {
return false;
}
$permissions = $businessUser->pivot->permissions ?? [];
return in_array($permission, $permissions);
}
/**
* Check multiple permissions (user needs ANY of them)
*/
public static function hasAnyPermission(array $permissions): bool
{
foreach ($permissions as $permission) {
if (self::hasPermission($permission)) {
return true;
}
}
return false;
}
/**
* Check multiple permissions (user needs ALL of them)
*/
public static function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $permission) {
if (! self::hasPermission($permission)) {
return false;
}
}
return true;
}
/**
* Get seller's business from product
* (Products don't have direct business_id, go through Brand)
*/
public static function fromProduct($product): ?Business
{
return $product->brand?->business;
}
}

24
app/Helpers/helpers.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
use App\Helpers\BusinessHelper;
if (! function_exists('currentBusiness')) {
function currentBusiness()
{
return BusinessHelper::current();
}
}
if (! function_exists('currentBusinessId')) {
function currentBusinessId()
{
return BusinessHelper::currentId();
}
}
if (! function_exists('hasBusinessPermission')) {
function hasBusinessPermission(string $permission): bool
{
return BusinessHelper::hasPermission($permission);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnalyticsDashboardController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.overview')) {
abort(403, 'Unauthorized to view analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30'); // days
$startDate = now()->subDays((int) $period);
// Key metrics
$metrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->distinct('product_id')
->count('product_id'),
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
];
// Traffic trend (daily breakdown)
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as total_events'),
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
)
->groupBy('date')
->orderBy('date')
->get();
// Top products by views
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id', DB::raw('COUNT(*) as view_count'))
->groupBy('product_id')
->orderByDesc('view_count')
->limit(10)
->with('product')
->get();
// High-value buyers
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
->active()
->orderByDesc('total_score')
->limit(10)
->with('buyerBusiness')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subHours(24))
->orderByDesc('detected_at')
->limit(10)
->with(['buyerBusiness', 'user'])
->get();
// Engagement score distribution
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw('CASE
WHEN total_score >= 80 THEN "Very High"
WHEN total_score >= 60 THEN "High"
WHEN total_score >= 40 THEN "Medium"
ELSE "Low"
END as score_range'),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.analytics.dashboard', compact(
'business',
'period',
'metrics',
'trafficTrend',
'topProducts',
'highValueBuyers',
'recentIntentSignals',
'engagementDistribution'
));
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BuyerIntelligenceController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.buyers')) {
abort(403, 'Unauthorized to view buyer intelligence');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
$startDate = now()->subDays((int) $period);
// Overall buyer metrics
$metrics = [
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->byTrend(BuyerEngagementScore::TREND_NEW)->count(),
];
// Build query based on filter
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
match ($filter) {
'high-value' => $buyersQuery->highValue(),
'at-risk' => $buyersQuery->atRisk(),
'new' => $buyersQuery->byTrend(BuyerEngagementScore::TREND_NEW),
default => $buyersQuery,
};
$buyers = $buyersQuery->orderByDesc('total_score')
->with('buyerBusiness')
->paginate(20);
// Engagement score distribution
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw('CASE
WHEN total_score >= 80 THEN "Very High (80-100)"
WHEN total_score >= 60 THEN "High (60-79)"
WHEN total_score >= 40 THEN "Medium (40-59)"
WHEN total_score >= 20 THEN "Low (20-39)"
ELSE "Very Low (0-19)"
END as score_range'),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
// Trend distribution
$trendDistribution = BuyerEngagementScore::forBusiness($business->id)->select('engagement_trend')
->selectRaw('COUNT(*) as count')
->groupBy('engagement_trend')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subDays(7))
->orderByDesc('detected_at')
->with(['buyerBusiness', 'user'])
->limit(20)
->get();
// Intent signal breakdown
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->select('signal_type')
->selectRaw('COUNT(*) as count')
->selectRaw('AVG(signal_strength) as avg_strength')
->groupBy('signal_type')
->orderByDesc('count')
->get();
return view('seller.analytics.buyers', compact(
'business',
'period',
'filter',
'metrics',
'buyers',
'scoreDistribution',
'trendDistribution',
'recentIntentSignals',
'signalBreakdown'
));
}
public function show(Request $request, Business $buyer)
{
if (! hasBusinessPermission('analytics.buyers')) {
abort(403, 'Unauthorized to view buyer intelligence');
}
$business = currentBusiness();
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
$startDate = now()->subDays((int) $period);
// Get engagement score
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
// Activity timeline
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as product_views'),
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
DB::raw('SUM(CASE WHEN added_to_cart = 1 THEN 1 ELSE 0 END) as cart_adds')
)
->groupBy('date')
->orderBy('date')
->get();
// Products viewed
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->selectRaw('AVG(time_on_page) as avg_time')
->selectRaw('SUM(CASE WHEN added_to_cart = 1 THEN 1 ELSE 0 END) as cart_adds')
->groupBy('product_id')
->orderByDesc('view_count')
->with('product')
->limit(20)
->get();
// Intent signals
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('detected_at', '>=', $startDate)
->orderByDesc('detected_at')
->limit(50)
->get();
// Email engagement
$emailEngagement = DB::table('email_interactions')
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
->join('business_user', 'users.id', '=', 'business_user.user_id')
->where('email_interactions.business_id', $business->id)
->where('business_user.business_id', $buyer->id)
->where('email_interactions.sent_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_sent')
->selectRaw('SUM(open_count) as total_opens')
->selectRaw('SUM(click_count) as total_clicks')
->selectRaw('AVG(engagement_score) as avg_engagement')
->first();
// Order history
$orderHistory = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as order_count'),
DB::raw('SUM(total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
$totalOrders = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->selectRaw('COUNT(*) as count')
->selectRaw('SUM(total) as total_revenue')
->selectRaw('AVG(total) as avg_order_value')
->first();
return view('seller.analytics.buyer-detail', compact(
'buyer',
'period',
'engagementScore',
'activityTimeline',
'productsViewed',
'intentSignals',
'emailEngagement',
'orderHistory',
'totalOrders'
));
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\EmailCampaign;
use App\Models\Analytics\EmailInteraction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketingAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Campaign overview metrics
$metrics = [
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
];
// Calculate average rates
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
: 0;
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
: 0;
// Campaign performance
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
->orderByDesc('sent_at')
->with('emailInteractions')
->paginate(20);
// Email engagement over time
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('COUNT(*) as sent'),
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
)
->groupBy('date')
->orderBy('date')
->get();
// Top performing campaigns
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->where('total_sent', '>', 0)
->orderByRaw('(total_clicked / total_sent) DESC')
->limit(10)
->get();
// Email client breakdown
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('email_client')
->select('email_client')
->selectRaw('COUNT(*) as count')
->groupBy('email_client')
->orderByDesc('count')
->get();
// Device type breakdown
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('device_type')
->select('device_type')
->selectRaw('COUNT(*) as count')
->groupBy('device_type')
->orderByDesc('count')
->get();
// Engagement score distribution
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw('CASE
WHEN engagement_score >= 80 THEN "High"
WHEN engagement_score >= 50 THEN "Medium"
WHEN engagement_score > 0 THEN "Low"
ELSE "None"
END as score_range'),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.analytics.marketing', compact(
'business',
'period',
'metrics',
'campaigns',
'engagementTrend',
'topCampaigns',
'emailClients',
'deviceTypes',
'engagementScores'
));
}
public function campaign(Request $request, EmailCampaign $campaign)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
// Verify campaign belongs to user's business
if ($campaign->business_id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this campaign');
}
// Campaign metrics
$metrics = [
'total_sent' => $campaign->total_sent,
'total_delivered' => $campaign->total_delivered,
'total_bounced' => $campaign->total_bounced,
'total_opened' => $campaign->total_opened,
'total_clicked' => $campaign->total_clicked,
'open_rate' => $campaign->open_rate,
'click_rate' => $campaign->click_rate,
'bounce_rate' => $campaign->total_sent > 0
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
: 0,
];
// Interaction timeline
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('SUM(open_count) as opens'),
DB::raw('SUM(click_count) as clicks')
)
->groupBy('date')
->orderBy('date')
->get();
// Top engaged recipients
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->orderByDesc('engagement_score')
->limit(20)
->with('recipientUser')
->get();
// Click breakdown by URL
$clicksByUrl = DB::table('email_clicks')
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
->where('email_interactions.campaign_id', $campaign->id)
->select('email_clicks.url', 'email_clicks.link_identifier')
->selectRaw('COUNT(*) as click_count')
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
->orderByDesc('click_count')
->get();
return view('seller.analytics.campaign-detail', compact(
'campaign',
'metrics',
'timeline',
'topRecipients',
'clicksByUrl'
));
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Helpers\BusinessHelper;
use App\Http\Controllers\Controller;
use App\Models\Analytics\ProductView;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ProductAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product performance metrics
$productMetrics = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = 1 THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = 1 THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = 1 THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = 1 THEN 1 ELSE 0 END) as cart_additions')
->groupBy('product_id')
->orderByDesc('total_views')
->with('product.brand')
->paginate(20);
// Product view trend
$viewTrend = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views'),
DB::raw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
)
->groupBy('date')
->orderBy('date')
->get();
// High engagement products (quality over quantity)
$highEngagementProducts = ProductView::forBusiness($business->id)->highEngagement()
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as engagement_count')
->selectRaw('AVG(time_on_page) as avg_time')
->groupBy('product_id')
->orderByDesc('engagement_count')
->limit(10)
->with('product')
->get();
// Products with most cart additions (high intent)
$topCartProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->where('added_to_cart', true)
->select('product_id')
->selectRaw('COUNT(*) as cart_count')
->groupBy('product_id')
->orderByDesc('cart_count')
->limit(10)
->with('product')
->get();
// Engagement breakdown
$engagementBreakdown = [
'zoomed_image' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('zoomed_image', true)->count(),
'watched_video' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('watched_video', true)->count(),
'downloaded_spec' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('downloaded_spec', true)->count(),
'added_to_cart' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_cart', true)->count(),
'added_to_wishlist' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_wishlist', true)->count(),
];
return view('seller.analytics.products', compact(
'business',
'period',
'productMetrics',
'viewTrend',
'highEngagementProducts',
'topCartProducts',
'engagementBreakdown'
));
}
public function show(Request $request, Product $product)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
// Verify product belongs to user's business brands
$sellerBusiness = BusinessHelper::fromProduct($product);
if ($sellerBusiness->id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this product');
}
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product-specific metrics
$metrics = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('COUNT(DISTINCT session_id) as unique_sessions')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('MAX(time_on_page) as max_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = 1 THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = 1 THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = 1 THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = 1 THEN 1 ELSE 0 END) as cart_additions')
->first();
// View trend
$viewTrend = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views')
)
->groupBy('date')
->orderBy('date')
->get();
// Top buyers viewing this product
$topBuyers = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->whereNotNull('buyer_business_id')
->select('buyer_business_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->groupBy('buyer_business_id')
->orderByDesc('view_count')
->limit(10)
->with('buyerBusiness')
->get();
// Traffic sources
$trafficSources = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select('source')
->selectRaw('COUNT(*) as count')
->groupBy('source')
->orderByDesc('count')
->get();
return view('seller.analytics.product-detail', compact(
'product',
'period',
'metrics',
'viewTrend',
'topBuyers',
'trafficSources'
));
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SalesAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.sales')) {
abort(403, 'Unauthorized to view sales analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Sales funnel metrics
$funnelMetrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'sessions_with_product_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('product_views', '>', 0)
->count(),
'sessions_with_cart' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('cart_additions', '>', 0)
->count(),
'checkout_initiated' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('checkout_initiated', true)
->count(),
'orders_completed' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('order_completed', true)
->count(),
];
// Calculate conversion rates
$funnelMetrics['product_view_rate'] = $funnelMetrics['total_sessions'] > 0
? round(($funnelMetrics['sessions_with_product_views'] / $funnelMetrics['total_sessions']) * 100, 2)
: 0;
$funnelMetrics['cart_rate'] = $funnelMetrics['sessions_with_product_views'] > 0
? round(($funnelMetrics['sessions_with_cart'] / $funnelMetrics['sessions_with_product_views']) * 100, 2)
: 0;
$funnelMetrics['checkout_rate'] = $funnelMetrics['sessions_with_cart'] > 0
? round(($funnelMetrics['checkout_initiated'] / $funnelMetrics['sessions_with_cart']) * 100, 2)
: 0;
$funnelMetrics['conversion_rate'] = $funnelMetrics['checkout_initiated'] > 0
? round(($funnelMetrics['orders_completed'] / $funnelMetrics['checkout_initiated']) * 100, 2)
: 0;
// Sales metrics from orders table (if exists)
$salesMetrics = DB::table('orders')
->where('seller_business_id', $business->id)
->where('created_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('SUM(total) as total_revenue')
->selectRaw('AVG(total) as avg_order_value')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->first();
// Revenue trend
$revenueTrend = DB::table('orders')
->where('seller_business_id', $business->id)
->where('created_at', '>=', $startDate)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as orders'),
DB::raw('SUM(total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
// Conversion funnel trend
$conversionTrend = UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->select(
DB::raw('DATE(started_at) as date'),
DB::raw('COUNT(*) as sessions'),
DB::raw('SUM(CASE WHEN product_views > 0 THEN 1 ELSE 0 END) as with_views'),
DB::raw('SUM(CASE WHEN cart_additions > 0 THEN 1 ELSE 0 END) as with_cart'),
DB::raw('SUM(CASE WHEN checkout_initiated = 1 THEN 1 ELSE 0 END) as checkouts'),
DB::raw('SUM(CASE WHEN order_completed = 1 THEN 1 ELSE 0 END) as orders')
)
->groupBy('date')
->orderBy('date')
->get();
// Top revenue products
$topProducts = DB::table('order_items')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->join('products', 'order_items.product_id', '=', 'products.id')
->where('orders.seller_business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('products.id', 'products.name')
->selectRaw('COUNT(order_items.id) as units_sold')
->selectRaw('SUM(order_items.price * order_items.quantity) as revenue')
->groupBy('products.id', 'products.name')
->orderByDesc('revenue')
->limit(10)
->get();
// Cart abandonment analysis
$cartAbandonment = [
'total_carts' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('cart_additions', '>', 0)
->count(),
'abandoned_carts' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('cart_additions', '>', 0)
->where('order_completed', false)
->count(),
];
$cartAbandonment['abandonment_rate'] = $cartAbandonment['total_carts'] > 0
? round(($cartAbandonment['abandoned_carts'] / $cartAbandonment['total_carts']) * 100, 2)
: 0;
// Top buyers by revenue
$topBuyers = DB::table('orders')
->where('seller_business_id', $business->id)
->where('created_at', '>=', $startDate)
->join('businesses', 'orders.buyer_business_id', '=', 'businesses.id')
->select('businesses.id', 'businesses.name')
->selectRaw('COUNT(orders.id) as order_count')
->selectRaw('SUM(orders.total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('total_revenue')
->limit(10)
->get();
return view('seller.analytics.sales', compact(
'business',
'period',
'funnelMetrics',
'salesMetrics',
'revenueTrend',
'conversionTrend',
'topProducts',
'cartAbandonment',
'topBuyers'
));
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\AnalyticsTracker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TrackingController extends Controller
{
protected AnalyticsTracker $tracker;
public function __construct(AnalyticsTracker $tracker)
{
$this->tracker = $tracker;
}
/**
* Initialize or update session
*/
public function session(Request $request)
{
try {
$session = $this->tracker->startSession();
return response()->json([
'success' => true,
'session_id' => $session->session_id,
]);
} catch (\Exception $e) {
Log::error('Analytics session tracking failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Session tracking failed',
], 500);
}
}
/**
* Track various analytics events
*/
public function track(Request $request)
{
try {
$eventType = $request->input('event_type');
switch ($eventType) {
case 'page_view':
$this->trackPageView($request);
break;
case 'product_view':
$this->trackProductView($request);
break;
case 'page_engagement':
$this->trackPageEngagement($request);
break;
case 'click':
$this->trackClick($request);
break;
default:
$this->trackGenericEvent($request);
}
return response()->json(['success' => true]);
} catch (\Exception $e) {
Log::error('Analytics tracking failed', [
'event_type' => $request->input('event_type'),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Tracking failed',
], 500);
}
}
/**
* Track page view
*/
protected function trackPageView(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_view',
'navigation',
'view',
null,
null,
[
'url' => $request->input('url'),
'title' => $request->input('title'),
'referrer' => $request->input('referrer'),
]
);
}
/**
* Track product view with engagement signals
*/
protected function trackProductView(Request $request): void
{
$productId = $request->input('product_id');
if (! $productId) {
return;
}
$product = Product::find($productId);
if (! $product) {
return;
}
$signals = [
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
'zoomed_image' => $request->boolean('zoomed_image'),
'watched_video' => $request->boolean('watched_video'),
'downloaded_spec' => $request->boolean('downloaded_spec'),
'added_to_cart' => $request->boolean('added_to_cart'),
'added_to_wishlist' => $request->boolean('added_to_wishlist'),
];
$this->tracker->trackProductView($product, $signals);
}
/**
* Track generic page engagement
*/
protected function trackPageEngagement(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_engagement',
'engagement',
'interact',
null,
null,
[
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
]
);
}
/**
* Track click event
*/
protected function trackClick(Request $request): void
{
$this->tracker->trackClick(
$request->input('element_type', 'unknown'),
$request->input('element_id'),
$request->input('element_label'),
$request->input('url'),
[
'timestamp' => $request->input('timestamp'),
]
);
}
/**
* Track generic event
*/
protected function trackGenericEvent(Request $request): void
{
$this->tracker->trackEvent(
$request->input('event_type', 'custom'),
$request->input('category', 'general'),
$request->input('action', 'action'),
$request->input('subject_id'),
$request->input('subject_type'),
$request->input('metadata', [])
);
}
}

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Business;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UserController extends Controller
@@ -25,14 +28,57 @@ class UserController extends Controller
// Load users with their pivot data (contact_type, is_primary, permissions)
$users = $business->users()
->withPivot('contact_type', 'is_primary', 'permissions')
->withPivot('contact_type', 'is_primary', 'permissions', 'role')
->orderBy('is_primary', 'desc')
->orderBy('first_name')
->get();
// Available analytics permissions
$analyticsPermissions = [
'analytics.overview' => 'Access main analytics dashboard',
'analytics.products' => 'View product performance analytics',
'analytics.marketing' => 'View marketing and email analytics',
'analytics.sales' => 'View sales intelligence and pipeline',
'analytics.buyers' => 'View buyer intelligence and engagement',
'analytics.export' => 'Export analytics data',
];
return view('business.users.index', [
'business' => $business,
'users' => $users,
'analyticsPermissions' => $analyticsPermissions,
]);
}
/**
* Update user permissions.
*/
public function updatePermissions(Request $request, User $user): JsonResponse
{
$business = auth()->user()->businesses()->first();
if (! $business) {
return response()->json(['error' => 'No business found'], 404);
}
// Verify user belongs to this business
if (! $business->users->contains($user->id)) {
return response()->json(['error' => 'User not found in this business'], 404);
}
$validated = $request->validate([
'permissions' => 'array',
'permissions.*' => 'string',
]);
// Update permissions in pivot table
$business->users()->updateExistingPivot($user->id, [
'permissions' => $validated['permissions'] ?? [],
]);
return response()->json([
'success' => true,
'message' => 'Permissions updated successfully',
]);
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandBrowseController extends Controller
{
/**
* Show brand menu for buyers to browse and order
* This is the main product browsing interface for buyers
*
* @return \Illuminate\View\View
*/
public function browse(Request $request, string $businessSlug, string $brandHashid)
{
// Manually resolve business and brand (cross-tenant access allowed)
// Buyers can browse ANY seller's brand menu
$business = Business::where('slug', $businessSlug)->firstOrFail();
$brand = Brand::where('hashid', $brandHashid)
->where('business_id', $business->id)
->where('is_active', true)
->firstOrFail();
// Load brand with business relationship
$brand->load('business');
// Get products organized by product line
$products = $brand->products()
->with(['strain', 'images', 'productLine'])
->where('is_active', true)
->orderBy('product_line_id')
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $products->groupBy(function ($product) {
return $product->productLine ? $product->productLine->name : 'Other Products';
});
// Get other brands from same business
$otherBrands = $business
->brands()
->where('id', '!=', $brand->id)
->where('is_active', true)
->get();
// Mark this as buyer view
$isSeller = false;
return view('seller.brands.preview', compact(
'business',
'brand',
'products',
'productsByLine',
'otherBrands',
'isSeller'
));
}
}

View File

@@ -56,25 +56,27 @@ class DashboardController extends Controller
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get order IDs that have items matching our brands
$currentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$currentStart, $currentEnd]))
->pluck('order_id')
->unique();
// Get order IDs and revenue in single optimized queries using joins
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
$previousOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$previousStart, $previousEnd]))
->pluck('order_id')
->unique();
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
// Revenue
$currentRevenue = \App\Models\Order::whereIn('id', $currentOrderIds)->sum('total') / 100;
$previousRevenue = \App\Models\Order::whereIn('id', $previousOrderIds)->sum('total') / 100;
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
// Orders count
$currentOrders = $currentOrderIds->count();
$previousOrders = $previousOrderIds->count();
$currentOrders = $currentStats->order_count ?? 0;
$previousOrders = $previousStats->order_count ?? 0;
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
// Products count (active products for selected brand(s))
@@ -188,16 +190,11 @@ class DashboardController extends Controller
$start = now()->sub($count, $unit)->startOfDay();
$end = now()->endOfDay();
// Get all order IDs for the period
$orderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$start, $end]))
->pluck('order_id')
->unique();
// Get orders with dates
$orders = \App\Models\Order::whereIn('id', $orderIds)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, SUM(total) as revenue')
// Optimized query using join instead of subquery
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$start, $end])
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
->groupBy('date')
->orderBy('date', 'asc')
->get();

View File

@@ -0,0 +1,230 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class BrandController extends Controller
{
/**
* Display a listing of brands for the business
*/
public function index(Request $request, Business $business)
{
$brands = $business->brands()
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.brands.index', compact('business', 'brands'));
}
/**
* Show the form for creating a new brand
*/
public function create(Business $business)
{
return view('seller.brands.create', compact('business'));
}
/**
* Store a newly created brand in storage
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'tagline' => 'nullable|string|max:45',
'description' => 'nullable|string|max:300',
'long_description' => 'nullable|string|max:1000',
'website_url' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'unit_number' => 'nullable|string|max:50',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:2',
'zip_code' => 'nullable|string|max:10',
'phone' => 'nullable|string|max:20',
'logo' => 'nullable|image|max:2048',
'banner' => 'nullable|image|max:4096',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_active' => 'boolean',
'instagram_handle' => 'nullable|string|max:255',
'facebook_url' => 'nullable|url|max:255',
'twitter_handle' => 'nullable|string|max:255',
'youtube_url' => 'nullable|url|max:255',
]);
// Automatically add https:// to website_url if not present
if ($request->filled('website_url')) {
$url = $validated['website_url'];
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$validated['website_url'] = 'https://'.$url;
}
}
// Generate slug from name
$validated['slug'] = Str::slug($validated['name']);
// Handle logo upload
if ($request->hasFile('logo')) {
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner upload
if ($request->hasFile('banner')) {
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Create brand
$brand = $business->brands()->create($validated);
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('success', 'Brand created successfully!');
}
/**
* Show the form for editing the specified brand
*/
public function edit(Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
return view('seller.brands.edit', compact('business', 'brand'));
}
/**
* Update the specified brand in storage
*/
public function update(Request $request, Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'tagline' => 'nullable|string|max:45',
'description' => 'nullable|string|max:300',
'long_description' => 'nullable|string|max:1000',
'website_url' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'unit_number' => 'nullable|string|max:50',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:2',
'zip_code' => 'nullable|string|max:10',
'phone' => 'nullable|string|max:20',
'logo' => 'nullable|image|max:2048',
'banner' => 'nullable|image|max:4096',
'remove_logo' => 'boolean',
'remove_banner' => 'boolean',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_active' => 'boolean',
'instagram_handle' => 'nullable|string|max:255',
'facebook_url' => 'nullable|url|max:255',
'twitter_handle' => 'nullable|string|max:255',
'youtube_url' => 'nullable|url|max:255',
]);
// Automatically add https:// to website_url if not present
if ($request->filled('website_url')) {
$url = $validated['website_url'];
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$validated['website_url'] = 'https://'.$url;
}
} else {
$validated['website_url'] = null;
}
// Update slug if name changed
if ($validated['name'] !== $brand->name) {
$validated['slug'] = Str::slug($validated['name']);
}
// Handle logo removal
if ($request->boolean('remove_logo') && $brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner removal
if ($request->boolean('remove_banner') && $brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner
if ($brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Remove form-only fields
unset($validated['remove_logo'], $validated['remove_banner']);
// Update brand
$brand->update($validated);
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('success', 'Brand updated successfully!');
}
/**
* Remove the specified brand from storage
*/
public function destroy(Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
// Delete logo and banner files
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
if ($brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
}
$brand->delete();
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('success', 'Brand deleted successfully!');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandPreviewController extends Controller
{
/**
* Show brand menu preview for sellers
* This allows sellers to preview how buyers will see their brand menu
*
* @return \Illuminate\View\View
*/
public function preview(Request $request, Business $business, Brand $brand)
{
// Verify the brand belongs to the business (business isolation)
if ($brand->business_id !== $business->id) {
abort(404, 'Brand not found for this business');
}
// Load brand with business relationship
$brand->load('business');
// Get products organized by product line
$products = $brand->products()
->with(['strain', 'images', 'productLine'])
->where('is_active', true)
->orderBy('product_line_id')
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $products->groupBy(function ($product) {
return $product->productLine ? $product->productLine->name : 'Other Products';
});
// Get other brands from same business
$otherBrands = $business
->brands()
->where('id', '!=', $brand->id)
->where('is_active', true)
->get();
// Mark this as seller view
$isSeller = true;
return view('seller.brands.preview', compact(
'business',
'brand',
'products',
'productsByLine',
'otherBrands',
'isSeller'
));
}
}

View File

@@ -191,6 +191,9 @@ class ProductController extends Controller
'discontinued' => 'Discontinued',
];
// Get audit history for this product
$audits = $product->audits()->with('user')->latest()->paginate(10);
return view('seller.products.edit', compact(
'business',
'product',
@@ -200,7 +203,8 @@ class ProductController extends Controller
'units',
'productLines',
'productTypes',
'statusOptions'
'statusOptions',
'audits'
));
}
@@ -242,6 +246,9 @@ class ProductController extends Controller
'discontinued' => 'Discontinued',
];
// Get audit history for this product
$audits = $product->audits()->with('user')->latest()->paginate(10);
return view('seller.products.edit1', compact(
'business',
'product',
@@ -251,7 +258,8 @@ class ProductController extends Controller
'units',
'productLines',
'productTypes',
'statusOptions'
'statusOptions',
'audits'
));
}

View File

@@ -30,13 +30,51 @@ class SettingsController extends Controller
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string',
'physical_address' => 'nullable|string|max:255',
'physical_suite' => 'nullable|string|max:50',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:2',
'physical_zipcode' => 'nullable|string|max:10',
'business_phone' => 'nullable|string|max:20',
'business_email' => 'nullable|email|max:255',
'logo' => 'nullable|image|max:2048', // 2MB max
'banner' => 'nullable|image|max:4096', // 4MB max
'remove_logo' => 'nullable|boolean',
'remove_banner' => 'nullable|boolean',
]);
// Handle logo removal
if ($request->has('remove_logo') && $business->logo_path) {
\Storage::disk('public')->delete($business->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo if exists
if ($business->logo_path) {
\Storage::disk('public')->delete($business->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('businesses/logos', 'public');
}
// Handle banner removal
if ($request->has('remove_banner') && $business->banner_path) {
\Storage::disk('public')->delete($business->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner if exists
if ($business->banner_path) {
\Storage::disk('public')->delete($business->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('businesses/banners', 'public');
}
// Remove file inputs from validated data (already handled above)
unset($validated['logo'], $validated['banner'], $validated['remove_logo'], $validated['remove_banner']);
$business->update($validated);
return redirect()
@@ -47,9 +85,144 @@ class SettingsController extends Controller
/**
* Display the users management settings page.
*/
public function users(Business $business)
public function users(Business $business, Request $request)
{
return view('seller.settings.users', compact('business'));
$query = $business->users();
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by account type (role)
if ($request->filled('account_type')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('name', $request->account_type);
});
}
// Filter by last login date range
if ($request->filled('last_login_start')) {
$query->where('last_login_at', '>=', $request->last_login_start);
}
if ($request->filled('last_login_end')) {
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query->with('roles')->paginate(15);
return view('seller.settings.users', compact('business', 'users'));
}
/**
* Store a newly created user invitation.
*/
public function inviteUser(Business $business, Request $request)
{
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'is_point_of_contact' => 'nullable|boolean',
]);
// Combine first and last name
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'email' => $validated['email'],
'phone' => $validated['phone'],
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Assign role
$user->assignRole($validated['role']);
// Associate with business with additional pivot data
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
]);
// TODO: Send invitation email with password reset link
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User invited successfully!');
}
/**
* Update user information and permissions.
*/
public function updateUser(Business $business, \App\Models\User $user, Request $request)
{
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,'.$user->id,
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'is_point_of_contact' => 'nullable|boolean',
'permissions' => 'nullable|array',
]);
// Combine first and last name
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
// Update user
$user->update([
'name' => $fullName,
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
]);
// Update role
$user->syncRoles([$validated['role']]);
// Update business_user pivot data
$business->users()->updateExistingPivot($user->id, [
'role' => $validated['role'],
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
'permissions' => $validated['permissions'] ?? null,
]);
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User updated successfully!');
}
/**
* Remove user from business.
*/
public function removeUser(Business $business, \App\Models\User $user)
{
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Detach user from business
$business->users()->detach($user->id);
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User removed successfully!');
}
/**
@@ -60,12 +233,52 @@ class SettingsController extends Controller
return view('seller.settings.orders', compact('business'));
}
/**
* Update the order settings.
*/
public function updateOrders(Business $business, Request $request)
{
$validated = $request->validate([
'separate_orders_by_brand' => 'nullable|boolean',
'auto_increment_order_ids' => 'nullable|boolean',
'show_mark_as_paid' => 'nullable|boolean',
'display_crm_license_on_orders' => 'nullable|boolean',
'order_minimum' => 'nullable|numeric|min:0',
'default_shipping_charge' => 'nullable|numeric|min:0',
'free_shipping_minimum' => 'nullable|numeric|min:0',
'order_disclaimer' => 'nullable|string|max:2000',
'order_invoice_footer' => 'nullable|string|max:1000',
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
'az_require_patient_count' => 'nullable|boolean',
'az_require_allotment_verification' => 'nullable|boolean',
]);
// Convert checkbox values (null means unchecked)
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
$business->update($validated);
return redirect()
->route('seller.business.settings.orders', $business->slug)
->with('success', 'Order settings updated successfully!');
}
/**
* Display the brands management page.
*/
public function brands(Business $business)
{
return view('seller.settings.brands', compact('business'));
$brands = $business->brands()
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.settings.brands', compact('business', 'brands'));
}
/**
@@ -84,6 +297,26 @@ class SettingsController extends Controller
return view('seller.settings.invoices', compact('business'));
}
/**
* Update the invoice settings.
*/
public function updateInvoices(Business $business, Request $request)
{
$validated = $request->validate([
'invoice_payable_company_name' => 'nullable|string|max:255',
'invoice_payable_address' => 'nullable|string|max:255',
'invoice_payable_city' => 'nullable|string|max:100',
'invoice_payable_state' => 'nullable|string|max:2',
'invoice_payable_zipcode' => 'nullable|string|max:10',
]);
$business->update($validated);
return redirect()
->route('seller.business.settings.invoices', $business->slug)
->with('success', 'Invoice settings updated successfully!');
}
/**
* Display the manage licenses page.
*/
@@ -108,6 +341,65 @@ class SettingsController extends Controller
return view('seller.settings.notifications', compact('business'));
}
/**
* Update the notification settings.
*
* EMAIL NOTIFICATION RULES DOCUMENTATION:
*
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
* Base: Email these addresses when a new order is placed
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
*
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
* Base: Email these addresses when an order is accepted
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
*
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
* Base: Email these addresses for inquiries
* - Sales reps associated with customer ALWAYS receive email
* - If field is blank AND no sales reps exist: company admins receive notifications
*
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
*
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
* Base: Email these addresses when inventory is low
*
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
* Base: Email these addresses when seller status changes
*/
public function updateNotifications(Business $business, Request $request)
{
$validated = $request->validate([
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
'new_order_do_not_send_to_admins' => 'nullable|boolean',
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_manual_order_email_notifications' => 'nullable|boolean',
'manual_order_emails_internal_only' => 'nullable|boolean',
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
]);
// Convert checkbox values (null means unchecked)
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
$business->update($validated);
return redirect()
->route('seller.business.settings.notifications', $business->slug)
->with('success', 'Notification settings updated successfully!');
}
/**
* Display the report settings page.
*/

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Jobs;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\EmailInteraction;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class CalculateEngagementScore implements ShouldQueue
{
use Queueable;
public function __construct(
public int $sellerBusinessId,
public int $buyerBusinessId
) {}
public function handle(): void
{
// Get or create engagement score record
$score = BuyerEngagementScore::firstOrNew([
'business_id' => $this->sellerBusinessId,
'buyer_business_id' => $this->buyerBusinessId,
]);
// Calculate session metrics
$sessionMetrics = UserSession::where('business_id', $this->sellerBusinessId)
->where('user_id', function ($query) {
$query->select('id')
->from('users')
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('business_user')
->whereColumn('business_user.user_id', 'users.id')
->where('business_user.business_id', $this->buyerBusinessId);
});
})
->selectRaw('
COUNT(*) as total_sessions,
SUM(page_views) as total_page_views,
SUM(product_views) as total_product_views,
MAX(last_activity_at) as last_interaction_at,
MIN(started_at) as first_interaction_at
')
->first();
// Calculate product view metrics
$productMetrics = ProductView::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->selectRaw('
COUNT(DISTINCT product_id) as unique_products_viewed,
SUM(CASE WHEN added_to_cart = 1 THEN 1 ELSE 0 END) as total_cart_additions
')
->first();
// Calculate email metrics
$emailMetrics = EmailInteraction::where('business_id', $this->sellerBusinessId)
->whereExists(function ($query) {
$query->select(DB::raw(1))
->from('users')
->whereColumn('email_interactions.recipient_user_id', 'users.id')
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('business_user')
->whereColumn('business_user.user_id', 'users.id')
->where('business_user.business_id', $this->buyerBusinessId);
});
})
->selectRaw('
SUM(open_count) as total_email_opens,
SUM(click_count) as total_email_clicks
')
->first();
// Get order metrics (assuming Order model exists)
$orderMetrics = DB::table('orders')
->where('seller_business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->selectRaw('
COUNT(*) as total_orders,
SUM(total) as total_order_value
')
->first();
// Update base metrics
$score->fill([
'total_sessions' => $sessionMetrics->total_sessions ?? 0,
'total_page_views' => $sessionMetrics->total_page_views ?? 0,
'total_product_views' => $sessionMetrics->total_product_views ?? 0,
'unique_products_viewed' => $productMetrics->unique_products_viewed ?? 0,
'total_email_opens' => $emailMetrics->total_email_opens ?? 0,
'total_email_clicks' => $emailMetrics->total_email_clicks ?? 0,
'total_cart_additions' => $productMetrics->total_cart_additions ?? 0,
'total_orders' => $orderMetrics->total_orders ?? 0,
'total_order_value' => $orderMetrics->total_order_value ?? 0,
'last_interaction_at' => $sessionMetrics->last_interaction_at,
'first_interaction_at' => $sessionMetrics->first_interaction_at,
]);
// Calculate recency score (0-100)
$score->updateDaysSinceLastInteraction();
$score->recency_score = $this->calculateRecencyScore($score->days_since_last_interaction);
// Calculate frequency score (0-100)
$score->frequency_score = $this->calculateFrequencyScore(
$score->total_sessions,
$score->total_product_views
);
// Calculate engagement score (0-100)
$score->engagement_score = $this->calculateEngagementScore($score);
// Calculate intent score based on intent signals (0-100)
$intentScore = IntentSignal::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('detected_at', '>', now()->subDays(30))
->avg('signal_strength');
$score->intent_score = $intentScore ?? 0;
// Calculate total weighted score
$score->calculateTotalScore();
// Determine engagement trend
$score->engagement_trend = $this->calculateTrend($score);
$score->save();
}
protected function calculateRecencyScore(int $daysSinceLastInteraction): int
{
if ($daysSinceLastInteraction <= 7) {
return 100;
} elseif ($daysSinceLastInteraction <= 14) {
return 80;
} elseif ($daysSinceLastInteraction <= 30) {
return 60;
} elseif ($daysSinceLastInteraction <= 60) {
return 40;
} elseif ($daysSinceLastInteraction <= 90) {
return 20;
}
return 0;
}
protected function calculateFrequencyScore(int $sessions, int $productViews): int
{
$score = 0;
// Session frequency
if ($sessions >= 20) {
$score += 50;
} elseif ($sessions >= 10) {
$score += 35;
} elseif ($sessions >= 5) {
$score += 20;
} elseif ($sessions >= 1) {
$score += 10;
}
// Product view frequency
if ($productViews >= 50) {
$score += 50;
} elseif ($productViews >= 25) {
$score += 35;
} elseif ($productViews >= 10) {
$score += 20;
} elseif ($productViews >= 1) {
$score += 10;
}
return min(100, $score);
}
protected function calculateEngagementScore(BuyerEngagementScore $score): int
{
$engagementScore = 0;
// Email engagement
if ($score->total_email_opens > 0) {
$engagementScore += 15;
}
if ($score->total_email_clicks > 0) {
$engagementScore += 25;
}
// Product engagement
if ($score->unique_products_viewed >= 10) {
$engagementScore += 20;
} elseif ($score->unique_products_viewed >= 5) {
$engagementScore += 10;
}
// Cart activity
if ($score->total_cart_additions > 0) {
$engagementScore += 25;
}
// Order activity
if ($score->total_orders > 0) {
$engagementScore += 15;
}
return min(100, $engagementScore);
}
protected function calculateTrend(BuyerEngagementScore $score): string
{
// If very new (less than 14 days), mark as new
$daysSinceFirst = $score->first_interaction_at
? now()->diffInDays($score->first_interaction_at)
: 0;
if ($daysSinceFirst < 14) {
return BuyerEngagementScore::TREND_NEW;
}
// Compare recent activity (last 14 days) vs previous period
$recentActivity = ProductView::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('viewed_at', '>', now()->subDays(14))
->count();
$previousActivity = ProductView::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->whereBetween('viewed_at', [now()->subDays(28), now()->subDays(14)])
->count();
if ($previousActivity == 0) {
return BuyerEngagementScore::TREND_STABLE;
}
$changePercent = (($recentActivity - $previousActivity) / $previousActivity) * 100;
if ($changePercent > 20) {
return BuyerEngagementScore::TREND_INCREASING;
} elseif ($changePercent < -20) {
return BuyerEngagementScore::TREND_DECLINING;
}
return BuyerEngagementScore::TREND_STABLE;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Jobs;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\UserSession;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessAnalyticsEvent implements ShouldQueue
{
use Queueable;
public function __construct(
public array $eventData
) {}
public function handle(): void
{
// Create the analytics event
$event = AnalyticsEvent::create($this->eventData);
// Update related session if exists
if (! empty($this->eventData['session_id'])) {
$session = UserSession::where('session_id', $this->eventData['session_id'])->first();
if ($session) {
$session->updateActivity();
// Update session metrics based on event type
match ($this->eventData['event_type']) {
'product_view' => $session->increment('product_views'),
'cart_add' => $session->update(['cart_additions' => $session->cart_additions + 1]),
'checkout_initiated' => $session->update(['checkout_initiated' => true]),
'order_completed' => $session->update(['order_completed' => true]),
default => null,
};
}
}
// Trigger engagement score recalculation if needed
if ($this->shouldRecalculateEngagement($event)) {
CalculateEngagementScore::dispatch(
$event->business_id,
$this->eventData['buyer_business_id'] ?? $event->business_id
)->onQueue('analytics');
}
}
protected function shouldRecalculateEngagement(AnalyticsEvent $event): bool
{
// Recalculate on significant events
return in_array($event->event_type, [
'product_view',
'email_open',
'email_click',
'cart_add',
'order_completed',
]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsEvent extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'business_id',
'event_type',
'event_category',
'event_action',
'subject_id',
'subject_type',
'user_id',
'session_id',
'fingerprint',
'url',
'referrer',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
'user_agent',
'device_type',
'browser',
'os',
'ip_address',
'country_code',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
];
/**
* Boot the model and apply global scopes
*/
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
// Auto-set business_id on creation
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
/**
* Relationships
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Scopes
*/
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeOfType($query, string $type)
{
return $query->where('event_type', $type);
}
public function scopeForSubject($query, string $type, int $id)
{
return $query->where('subject_type', $type)
->where('subject_id', $id);
}
public function scopeDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('created_at', [$startDate, $endDate]);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BuyerEngagementScore extends Model
{
protected $fillable = [
'business_id',
'buyer_business_id',
'total_score',
'recency_score',
'frequency_score',
'engagement_score',
'intent_score',
'total_sessions',
'total_page_views',
'total_product_views',
'unique_products_viewed',
'total_email_opens',
'total_email_clicks',
'total_cart_additions',
'total_orders',
'total_order_value',
'last_interaction_at',
'first_interaction_at',
'days_since_last_interaction',
'engagement_trend',
];
protected $casts = [
'total_order_value' => 'decimal:2',
'last_interaction_at' => 'datetime',
'first_interaction_at' => 'datetime',
];
// Engagement trends
const TREND_INCREASING = 'increasing';
const TREND_STABLE = 'stable';
const TREND_DECLINING = 'declining';
const TREND_NEW = 'new';
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function intentSignals(): HasMany
{
return $this->hasMany(IntentSignal::class, 'buyer_business_id', 'buyer_business_id')
->where('business_id', $this->business_id);
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeHighValue($query)
{
return $query->where('total_score', '>=', 70);
}
public function scopeAtRisk($query)
{
return $query->where('engagement_trend', self::TREND_DECLINING)
->where('days_since_last_interaction', '>', 30);
}
public function scopeActive($query)
{
return $query->where('days_since_last_interaction', '<=', 30);
}
public function scopeByTrend($query, string $trend)
{
return $query->where('engagement_trend', $trend);
}
public function calculateTotalScore()
{
// Weighted scoring algorithm
$this->total_score = min(100, (
($this->recency_score * 0.25) +
($this->frequency_score * 0.25) +
($this->engagement_score * 0.30) +
($this->intent_score * 0.20)
));
return $this->total_score;
}
public function updateDaysSinceLastInteraction()
{
if ($this->last_interaction_at) {
$this->days_since_last_interaction = now()->diffInDays($this->last_interaction_at);
}
}
public function isHighValue(): bool
{
return $this->total_score >= 70;
}
public function isAtRisk(): bool
{
return $this->engagement_trend === self::TREND_DECLINING
&& $this->days_since_last_interaction > 30;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClickTracking extends Model
{
public $timestamps = false;
const CREATED_AT = 'clicked_at';
const UPDATED_AT = null;
protected $table = 'click_tracking';
protected $fillable = [
'business_id',
'user_id',
'session_id',
'element_type',
'element_id',
'element_label',
'url',
'page_url',
'clicked_at',
'metadata',
];
protected $casts = [
'clicked_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeForElement($query, string $type, int $id)
{
return $query->where('element_type', $type)
->where('element_id', $id);
}
public function scopeOnPage($query, string $pageUrl)
{
return $query->where('page_url', $pageUrl);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class EmailCampaign extends Model
{
use SoftDeletes;
protected $fillable = [
'business_id',
'name',
'subject',
'content',
'status',
'scheduled_at',
'sent_at',
'total_recipients',
'total_sent',
'total_delivered',
'total_opened',
'total_clicked',
'total_bounced',
'metadata',
];
protected $casts = [
'scheduled_at' => 'datetime',
'sent_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function interactions(): HasMany
{
return $this->hasMany(EmailInteraction::class, 'campaign_id');
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function getOpenRateAttribute()
{
if ($this->total_delivered == 0) {
return 0;
}
return round(($this->total_opened / $this->total_delivered) * 100, 2);
}
public function getClickRateAttribute()
{
if ($this->total_delivered == 0) {
return 0;
}
return round(($this->total_clicked / $this->total_delivered) * 100, 2);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmailClick extends Model
{
public $timestamps = false;
const CREATED_AT = 'clicked_at';
const UPDATED_AT = null;
protected $fillable = [
'business_id',
'email_interaction_id',
'url',
'link_identifier',
'clicked_at',
];
protected $casts = [
'clicked_at' => 'datetime',
];
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function emailInteraction(): BelongsTo
{
return $this->belongsTo(EmailInteraction::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeForUrl($query, string $url)
{
return $query->where('url', $url);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class EmailInteraction extends Model
{
protected $fillable = [
'business_id',
'campaign_id',
'recipient_user_id',
'recipient_email',
'tracking_token',
'sent_at',
'delivered_at',
'bounced_at',
'bounce_reason',
'first_opened_at',
'last_opened_at',
'open_count',
'first_clicked_at',
'last_clicked_at',
'click_count',
'email_client',
'device_type',
'engagement_score',
'metadata',
];
protected $casts = [
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'bounced_at' => 'datetime',
'first_opened_at' => 'datetime',
'last_opened_at' => 'datetime',
'first_clicked_at' => 'datetime',
'last_clicked_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
if (! $model->tracking_token) {
$model->tracking_token = Str::random(64);
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function campaign(): BelongsTo
{
return $this->belongsTo(EmailCampaign::class, 'campaign_id');
}
public function recipientUser(): BelongsTo
{
return $this->belongsTo(User::class, 'recipient_user_id');
}
public function clicks(): HasMany
{
return $this->hasMany(EmailClick::class, 'email_interaction_id');
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function recordOpen(?string $emailClient = null, ?string $deviceType = null)
{
$now = now();
if (! $this->first_opened_at) {
$this->first_opened_at = $now;
}
$this->last_opened_at = $now;
$this->open_count++;
if ($emailClient) {
$this->email_client = $emailClient;
}
if ($deviceType) {
$this->device_type = $deviceType;
}
$this->calculateEngagementScore();
$this->save();
$this->campaign->increment('total_opened');
}
public function recordClick(string $url, ?string $linkIdentifier = null)
{
$now = now();
if (! $this->first_clicked_at) {
$this->first_clicked_at = $now;
}
$this->last_clicked_at = $now;
$this->click_count++;
$this->calculateEngagementScore();
$this->save();
EmailClick::create([
'business_id' => $this->business_id,
'email_interaction_id' => $this->id,
'url' => $url,
'link_identifier' => $linkIdentifier,
'clicked_at' => $now,
]);
if ($this->click_count == 1) {
$this->campaign->increment('total_clicked');
}
}
protected function calculateEngagementScore()
{
$score = 0;
if ($this->open_count > 0) {
$score += 20;
}
if ($this->open_count > 2) {
$score += 15;
}
if ($this->click_count > 0) {
$score += 40;
}
if ($this->click_count > 1) {
$score += 25;
}
$this->engagement_score = min($score, 100);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IntentSignal extends Model
{
protected $fillable = [
'business_id',
'buyer_business_id',
'user_id',
'signal_type',
'signal_strength',
'subject_type',
'subject_id',
'session_id',
'context',
'detected_at',
];
protected $casts = [
'detected_at' => 'datetime',
'context' => 'array',
];
// Signal types
const TYPE_HIGH_ENGAGEMENT = 'high_engagement';
const TYPE_REPEAT_VIEWS = 'repeat_views';
const TYPE_PRICE_CHECK = 'price_check';
const TYPE_SPEC_DOWNLOAD = 'spec_download';
const TYPE_CART_ABANDON = 'cart_abandon';
const TYPE_EMAIL_CLICK = 'email_click';
const TYPE_SEARCH_PATTERN = 'search_pattern';
const TYPE_COMPETITOR_COMPARISON = 'competitor_comparison';
// Signal strengths
const STRENGTH_LOW = 10;
const STRENGTH_MEDIUM = 50;
const STRENGTH_HIGH = 75;
const STRENGTH_CRITICAL = 100;
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeHighIntent($query)
{
return $query->where('signal_strength', '>=', self::STRENGTH_HIGH);
}
public function scopeOfType($query, string $type)
{
return $query->where('signal_type', $type);
}
public function scopeForBuyer($query, int $buyerBusinessId)
{
return $query->where('buyer_business_id', $buyerBusinessId);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Product;
use App\Models\Scopes\BusinessScope;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductView extends Model
{
protected $fillable = [
'business_id',
'product_id',
'user_id',
'buyer_business_id',
'session_id',
'viewed_at',
'time_on_page',
'scroll_depth',
'zoomed_image',
'watched_video',
'downloaded_spec',
'added_to_cart',
'added_to_wishlist',
'source',
'referrer',
'utm_campaign',
'device_type',
];
protected $casts = [
'viewed_at' => 'datetime',
'zoomed_image' => 'boolean',
'watched_video' => 'boolean',
'downloaded_spec' => 'boolean',
'added_to_cart' => 'boolean',
'added_to_wishlist' => 'boolean',
];
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeHighEngagement($query)
{
return $query->where(function ($q) {
$q->where('time_on_page', '>', 30)
->orWhere('zoomed_image', true)
->orWhere('watched_video', true)
->orWhere('downloaded_spec', true);
});
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Scopes\BusinessScope;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserSession extends Model
{
protected $fillable = [
'business_id',
'user_id',
'session_id',
'fingerprint',
'started_at',
'ended_at',
'last_activity_at',
'duration_seconds',
'page_views',
'product_views',
'cart_additions',
'checkout_initiated',
'order_completed',
'entry_url',
'exit_url',
'referrer',
'utm_source',
'utm_medium',
'utm_campaign',
'device_type',
'browser',
'os',
'country_code',
];
protected $casts = [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'last_activity_at' => 'datetime',
'checkout_initiated' => 'boolean',
'order_completed' => 'boolean',
];
protected static function booted(): void
{
parent::booted();
// Apply multi-tenancy scope
static::addGlobalScope(new BusinessScope);
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->withoutGlobalScope(BusinessScope::class)
->where('business_id', $businessId);
}
public function scopeActive($query)
{
return $query->whereNull('ended_at')
->where('last_activity_at', '>', now()->subMinutes(30));
}
public function scopeConverted($query)
{
return $query->where('order_completed', true);
}
public function endSession()
{
if (! $this->ended_at) {
$this->ended_at = now();
$this->duration_seconds = $this->started_at->diffInSeconds($this->ended_at);
$this->save();
}
}
public function updateActivity()
{
$this->last_activity_at = now();
$this->save();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,7 +13,7 @@ use Str;
class Brand extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
// Product Categories that can be organized under brands
public const PRODUCT_CATEGORIES = [
@@ -32,21 +33,33 @@ class Brand extends Model
'business_id',
// Brand Identity
'hashid',
'name',
'slug',
'sku_prefix', // SKU prefix for products
'description',
'long_description',
'tagline',
// Branding Assets
'logo_path',
'banner_path',
'website_url',
'colors', // JSON: hex color codes for theming
// Physical Address
'address',
'unit_number',
'city',
'state',
'zip_code',
'phone',
// Social Media
'instagram_handle',
'facebook_url',
'twitter_handle',
'youtube_url',
// Display Settings
'is_active',
@@ -162,14 +175,6 @@ class Brand extends Model
->get();
}
/**
* Get route key (slug for URLs)
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Generate slug from name
*/
@@ -178,6 +183,15 @@ class Brand extends Model
return Str::slug($this->name);
}
/**
* Get the storage path for this brand's assets
* Format: {hashid}/ (e.g., "52kn5/")
*/
public function getStoragePath(): string
{
return $this->hashid.'/';
}
/**
* Check if brand has a logo
*/

View File

@@ -172,6 +172,39 @@ class Business extends Model implements AuditableContract
'approved_at',
'approved_by',
'notes',
// Order Settings
'separate_orders_by_brand',
'auto_increment_order_ids',
'show_mark_as_paid',
'display_crm_license_on_orders',
'order_minimum',
'default_shipping_charge',
'free_shipping_minimum',
'order_disclaimer',
'order_invoice_footer',
'prevent_order_editing',
'az_require_patient_count',
'az_require_allotment_verification',
// Invoice Settings
'invoice_payable_company_name',
'invoice_payable_address',
'invoice_payable_city',
'invoice_payable_state',
'invoice_payable_zipcode',
// Notification Settings
'new_order_email_notifications',
'new_order_only_when_no_sales_rep',
'new_order_do_not_send_to_admins',
'order_accepted_email_notifications',
'enable_shipped_emails_for_sales_reps',
'platform_inquiry_email_notifications',
'enable_manual_order_email_notifications',
'manual_order_emails_internal_only',
'low_inventory_email_notifications',
'certified_seller_status_email_notifications',
];
protected $casts = [
@@ -186,6 +219,22 @@ class Business extends Model implements AuditableContract
'credit_limit' => 'decimal:2',
'tax_rate' => 'decimal:2',
'tax_exempt' => 'boolean',
// Order Settings
'separate_orders_by_brand' => 'boolean',
'auto_increment_order_ids' => 'boolean',
'show_mark_as_paid' => 'boolean',
'display_crm_license_on_orders' => 'boolean',
'order_minimum' => 'decimal:2',
'default_shipping_charge' => 'decimal:2',
'free_shipping_minimum' => 'decimal:2',
'az_require_patient_count' => 'boolean',
'az_require_allotment_verification' => 'boolean',
// Notification Settings
'new_order_only_when_no_sales_rep' => 'boolean',
'new_order_do_not_send_to_admins' => 'boolean',
'enable_shipped_emails_for_sales_reps' => 'boolean',
'enable_manual_order_email_notifications' => 'boolean',
'manual_order_emails_internal_only' => 'boolean',
];
// LeafLink-aligned Relationships

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,10 +14,12 @@ use Illuminate\Support\Str;
class Component extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
protected $fillable = [
'hashid',
'business_id',
'category_id',
'name',
'slug',
'sku',
@@ -70,6 +73,14 @@ class Component extends Model
return $this->belongsTo(Business::class);
}
/**
* Component belongs to a category
*/
public function category(): BelongsTo
{
return $this->belongsTo(ComponentCategory::class, 'category_id');
}
/**
* Products that use this component in their BOM
*/

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use App\Traits\HasHashid;
use DateTime;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Contact extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
// Contact Types for Cannabis Business (LeafLink-aligned)
public const CONTACT_TYPES = [
@@ -42,6 +43,8 @@ class Contact extends Model
];
protected $fillable = [
'hashid',
// Ownership
'business_id',
'location_id', // Optional - can be business-wide or location-specific

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessViaBrand;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -14,7 +15,7 @@ use OwenIt\Auditing\Contracts\Auditable;
class Product extends Model implements Auditable
{
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
use BelongsToBusinessViaBrand, HasFactory, HasHashid, \OwenIt\Auditing\Auditable, SoftDeletes;
protected $fillable = [
// Foreign Keys
@@ -23,8 +24,10 @@ class Product extends Model implements Auditable
'parent_product_id',
'packaging_id',
'unit_id',
'category_id',
// Product Identity
'hashid',
'name',
'slug',
'sku',
@@ -214,6 +217,11 @@ class Product extends Model implements Auditable
return $this->belongsTo(Unit::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'category_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(Product::class, 'parent_product_id');

View File

@@ -0,0 +1,340 @@
<?php
namespace App\Services;
use App\Events\HighIntentBuyerDetected;
use App\Helpers\BusinessHelper;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\ClickTracking;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use App\Models\Product;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
class AnalyticsTracker
{
protected ?string $sessionId = null;
protected ?int $businessId = null;
public function __construct()
{
$this->sessionId = session()->getId();
$this->businessId = BusinessHelper::currentId();
}
/**
* Track a product view with engagement signals
*/
public function trackProductView(
Product $product,
array $signals = []
): ProductView {
// Get seller business from product -> brand -> business
$sellerBusiness = BusinessHelper::fromProduct($product);
$productView = ProductView::create([
'business_id' => $sellerBusiness->id,
'product_id' => $product->id,
'user_id' => Auth::id(),
'buyer_business_id' => $this->businessId,
'session_id' => $this->sessionId,
'viewed_at' => now(),
'time_on_page' => $signals['time_on_page'] ?? null,
'scroll_depth' => $signals['scroll_depth'] ?? null,
'zoomed_image' => $signals['zoomed_image'] ?? false,
'watched_video' => $signals['watched_video'] ?? false,
'downloaded_spec' => $signals['downloaded_spec'] ?? false,
'added_to_cart' => $signals['added_to_cart'] ?? false,
'added_to_wishlist' => $signals['added_to_wishlist'] ?? false,
'source' => $signals['source'] ?? null,
'referrer' => Request::header('referer'),
'utm_campaign' => Request::input('utm_campaign'),
'device_type' => $this->getDeviceType(),
]);
// Also create analytics event
$this->trackEvent('product_view', 'product', 'view', $product->id, Product::class);
// Detect high-intent signals
$this->detectIntentSignals($productView, $sellerBusiness->id);
return $productView;
}
/**
* Track a click event
*/
public function trackClick(
string $elementType,
?int $elementId = null,
?string $elementLabel = null,
?string $url = null,
array $metadata = []
): ClickTracking {
return ClickTracking::create([
'business_id' => $this->businessId,
'user_id' => Auth::id(),
'session_id' => $this->sessionId,
'element_type' => $elementType,
'element_id' => $elementId,
'element_label' => $elementLabel,
'url' => $url,
'page_url' => Request::url(),
'clicked_at' => now(),
'metadata' => $metadata,
]);
}
/**
* Track email interaction
*/
public function trackEmailInteraction(
string $campaignId,
string $action,
array $data = []
): void {
$this->trackEvent(
"email_{$action}",
'email',
$action,
$campaignId,
'App\Models\Analytics\EmailCampaign',
$data
);
}
/**
* Track a generic analytics event
*/
public function trackEvent(
string $eventType,
string $category,
string $action,
?int $subjectId = null,
?string $subjectType = null,
array $metadata = []
): AnalyticsEvent {
return AnalyticsEvent::create([
'business_id' => $this->businessId,
'event_type' => $eventType,
'event_category' => $category,
'event_action' => $action,
'subject_id' => $subjectId,
'subject_type' => $subjectType,
'user_id' => Auth::id(),
'session_id' => $this->sessionId,
'fingerprint' => $this->getFingerprint(),
'url' => Request::url(),
'referrer' => Request::header('referer'),
'utm_source' => Request::input('utm_source'),
'utm_medium' => Request::input('utm_medium'),
'utm_campaign' => Request::input('utm_campaign'),
'utm_content' => Request::input('utm_content'),
'utm_term' => Request::input('utm_term'),
'user_agent' => Request::header('User-Agent'),
'device_type' => $this->getDeviceType(),
'browser' => $this->getBrowser(),
'os' => $this->getOS(),
'ip_address' => Request::ip(),
'country_code' => null, // Can be populated via GeoIP service
'metadata' => $metadata,
]);
}
/**
* Start or update user session
*/
public function startSession(): UserSession
{
$session = UserSession::firstOrNew([
'session_id' => $this->sessionId,
]);
if (! $session->exists) {
$session->fill([
'business_id' => $this->businessId,
'user_id' => Auth::id(),
'fingerprint' => $this->getFingerprint(),
'started_at' => now(),
'last_activity_at' => now(),
'entry_url' => Request::url(),
'referrer' => Request::header('referer'),
'utm_source' => Request::input('utm_source'),
'utm_medium' => Request::input('utm_medium'),
'utm_campaign' => Request::input('utm_campaign'),
'device_type' => $this->getDeviceType(),
'browser' => $this->getBrowser(),
'os' => $this->getOS(),
'country_code' => null,
]);
} else {
$session->updateActivity();
}
$session->save();
return $session;
}
/**
* Update session with page view
*/
public function updateSessionPageView(): void
{
$session = UserSession::where('business_id', $this->businessId)
->where('session_id', $this->sessionId)
->first();
if ($session) {
$session->increment('page_views');
$session->updateActivity();
$session->exit_url = Request::url();
$session->save();
}
}
/**
* Detect high-intent signals from product views
*/
protected function detectIntentSignals(ProductView $productView, int $sellerBusinessId): void
{
$signals = [];
// High engagement signal
if ($productView->time_on_page > 60 || $productView->zoomed_image || $productView->watched_video) {
$signals[] = [
'type' => IntentSignal::TYPE_HIGH_ENGAGEMENT,
'strength' => IntentSignal::STRENGTH_HIGH,
];
}
// Spec download signal (very high intent)
if ($productView->downloaded_spec) {
$signals[] = [
'type' => IntentSignal::TYPE_SPEC_DOWNLOAD,
'strength' => IntentSignal::STRENGTH_CRITICAL,
];
}
// Check for repeat views
$viewCount = ProductView::forBusiness($sellerBusinessId)
->where('product_id', $productView->product_id)
->where('buyer_business_id', $this->businessId)
->count();
if ($viewCount > 3) {
$signals[] = [
'type' => IntentSignal::TYPE_REPEAT_VIEWS,
'strength' => IntentSignal::STRENGTH_HIGH,
];
}
// Create intent signals and broadcast high-intent events
foreach ($signals as $signal) {
$intentSignal = IntentSignal::create([
'business_id' => $sellerBusinessId,
'buyer_business_id' => $this->businessId,
'user_id' => Auth::id(),
'signal_type' => $signal['type'],
'signal_strength' => $signal['strength'],
'subject_type' => Product::class,
'subject_id' => $productView->product_id,
'session_id' => $this->sessionId,
'detected_at' => now(),
'context' => [
'product_view_id' => $productView->id,
'time_on_page' => $productView->time_on_page,
'view_count' => $viewCount ?? 1,
],
]);
// Broadcast high-intent signals in real-time
if ($signal['strength'] >= IntentSignal::STRENGTH_HIGH) {
$engagementScore = BuyerEngagementScore::forBusiness($sellerBusinessId)
->where('buyer_business_id', $this->businessId)
->first();
broadcast(new HighIntentBuyerDetected(
$sellerBusinessId,
$this->businessId,
$intentSignal,
$engagementScore
));
}
}
}
/**
* Get device type from user agent
*/
protected function getDeviceType(): string
{
$userAgent = Request::header('User-Agent');
if (preg_match('/mobile|android|iphone|ipad|tablet/i', $userAgent)) {
return 'mobile';
}
return 'desktop';
}
/**
* Get browser from user agent
*/
protected function getBrowser(): ?string
{
$userAgent = Request::header('User-Agent');
if (preg_match('/chrome/i', $userAgent)) {
return 'Chrome';
} elseif (preg_match('/firefox/i', $userAgent)) {
return 'Firefox';
} elseif (preg_match('/safari/i', $userAgent)) {
return 'Safari';
} elseif (preg_match('/edge/i', $userAgent)) {
return 'Edge';
}
return null;
}
/**
* Get OS from user agent
*/
protected function getOS(): ?string
{
$userAgent = Request::header('User-Agent');
if (preg_match('/windows/i', $userAgent)) {
return 'Windows';
} elseif (preg_match('/mac/i', $userAgent)) {
return 'macOS';
} elseif (preg_match('/linux/i', $userAgent)) {
return 'Linux';
} elseif (preg_match('/android/i', $userAgent)) {
return 'Android';
} elseif (preg_match('/ios|iphone|ipad/i', $userAgent)) {
return 'iOS';
}
return null;
}
/**
* Generate fingerprint for anonymous tracking
*/
protected function getFingerprint(): string
{
$components = [
Request::ip(),
Request::header('User-Agent'),
Request::header('Accept-Language'),
];
return hash('sha256', implode('|', $components));
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Services;
use App\Mail\Seller\NewOrderReceivedMail;
use App\Mail\Seller\OrderCancelledMail;
use App\Mail\Seller\PaymentReceivedMail as SellerPaymentReceivedMail;
use App\Models\Business;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\User;
@@ -26,25 +27,294 @@ class SellerNotificationService
}
/**
* Notify sellers when a new order is received.
* Parse comma-separated email addresses from notification settings.
*/
public function newOrderReceived(Order $order): void
protected function parseEmailList(?string $emailList): array
{
$sellers = $this->getSellerUsers();
if (empty($emailList)) {
return [];
}
foreach ($sellers as $seller) {
// Send email
Mail::to($seller->email)->send(new NewOrderReceivedMail($order));
return array_filter(
array_map('trim', explode(',', $emailList)),
fn ($email) => ! empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)
);
}
// Create in-app notification
$this->notificationService->create(
user: $seller,
type: 'seller_new_order',
title: 'New Order Received',
message: "New order {$order->order_number} from {$order->business->name}. Total: $".number_format($order->total, 2),
actionUrl: route('seller.orders.show', $order),
notifiable: $order
);
/**
* Get the seller business from an order (the business that owns the product being sold).
*/
protected function getSellerBusinessFromOrder(Order $order): ?Business
{
// Get seller business from first order item's product's brand
$firstItem = $order->items()->with('product.brand.business')->first();
return $firstItem?->product?->brand?->business;
}
/**
* Check if buyer has any sales reps assigned.
*/
protected function buyerHasSalesRep(Business $buyer): bool
{
// TODO: Implement sales rep relationship checking when sales rep system is built
// For now, return false (no sales reps assigned)
return false;
}
/**
* Get sales reps assigned to a buyer.
*/
protected function getSalesRepsForBuyer(Business $buyer): \Illuminate\Support\Collection
{
// TODO: Implement sales rep relationship when sales rep system is built
// For now, return empty collection
return collect();
}
/**
* Get company admin users for a business.
*/
protected function getCompanyAdmins(Business $business): \Illuminate\Support\Collection
{
// Get users associated with this business who have admin role
return $business->users()
->whereHas('roles', function ($query) {
$query->where('name', User::ROLE_SUPER_ADMIN);
})
->get();
}
/**
* NEW ORDER EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'new_order_email_notifications' when new order is placed
* 2. If 'new_order_only_when_no_sales_rep' is TRUE: ONLY send if buyer has NO sales rep assigned
* 3. If 'new_order_do_not_send_to_admins' is TRUE: Do NOT send to company admins (only to custom addresses)
* 4. If 'new_order_do_not_send_to_admins' is FALSE: Send to BOTH custom addresses AND company admins
*/
public function newOrderReceived(Order $order, bool $isManualOrder = false): void
{
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
if (! $sellerBusiness) {
return;
}
$buyerBusiness = $order->business;
// Check manual order notification settings
if ($isManualOrder && ! $sellerBusiness->enable_manual_order_email_notifications) {
return; // Don't send notifications for manual orders if disabled
}
// RULE 2: Check if we should only send when buyer has no sales rep
if ($sellerBusiness->new_order_only_when_no_sales_rep) {
if ($this->buyerHasSalesRep($buyerBusiness)) {
return; // Buyer has sales rep, don't send
}
}
// RULE 1: Get custom email addresses from settings
$customEmails = $this->parseEmailList($sellerBusiness->new_order_email_notifications);
// RULE 3 & 4: Determine if we should send to admins
$sendToAdmins = ! $sellerBusiness->new_order_do_not_send_to_admins;
// Collect all recipients
$recipients = [];
// Add custom email addresses
foreach ($customEmails as $email) {
$recipients[] = $email;
}
// Add company admins if enabled
if ($sendToAdmins) {
$admins = $this->getCompanyAdmins($sellerBusiness);
foreach ($admins as $admin) {
$recipients[] = $admin->email;
}
}
// Remove duplicates
$recipients = array_unique($recipients);
// Send emails
foreach ($recipients as $email) {
Mail::to($email)->send(new NewOrderReceivedMail($order));
}
// Create in-app notifications for admin users only
if ($sendToAdmins) {
$admins = $this->getCompanyAdmins($sellerBusiness);
foreach ($admins as $admin) {
$this->notificationService->create(
user: $admin,
type: 'seller_new_order',
title: 'New Order Received',
message: "New order {$order->order_number} from {$buyerBusiness->name}. Total: $".number_format($order->total, 2),
actionUrl: route('seller.business.orders.show', [$sellerBusiness->slug, $order]),
notifiable: $order
);
}
}
}
/**
* ORDER ACCEPTED EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'order_accepted_email_notifications' when order is accepted
* 2. This notification has no conditional logic - always sends if addresses are configured
* 3. Note: 'enable_shipped_emails_for_sales_reps' is for SHIPPED status, not accepted (handled separately)
*/
public function orderAccepted(Order $order): void
{
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
if (! $sellerBusiness) {
return;
}
// RULE 1: Get email addresses from settings
$emails = $this->parseEmailList($sellerBusiness->order_accepted_email_notifications);
if (empty($emails)) {
return; // No emails configured
}
// Send emails to all configured addresses
foreach ($emails as $email) {
// TODO: Create OrderAcceptedMail class
// Mail::to($email)->send(new OrderAcceptedMail($order));
}
}
/**
* ORDER SHIPPED EMAIL NOTIFICATIONS (for sales reps)
*
* RULES:
* 1. If 'enable_shipped_emails_for_sales_reps' is TRUE: Send to sales reps assigned to the buyer
* 2. If FALSE: Don't send shipped notifications to sales reps
*/
public function orderShipped(Order $order): void
{
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
if (! $sellerBusiness) {
return;
}
// RULE 1: Check if sales rep shipped emails are enabled
if (! $sellerBusiness->enable_shipped_emails_for_sales_reps) {
return;
}
$buyerBusiness = $order->business;
// RULE 1: Get sales reps assigned to this buyer
$salesReps = $this->getSalesRepsForBuyer($buyerBusiness);
if ($salesReps->isEmpty()) {
return;
}
// Send emails to all sales reps
foreach ($salesReps as $salesRep) {
// TODO: Create OrderShippedForSalesRepMail class
// Mail::to($salesRep->email)->send(new OrderShippedForSalesRepMail($order));
}
}
/**
* PLATFORM INQUIRY EMAIL NOTIFICATIONS
*
* RULES:
* 1. Sales reps associated with customer ALWAYS receive email
* 2. Custom addresses in 'platform_inquiry_email_notifications' ALWAYS receive email
* 3. If NO custom addresses AND NO sales reps exist: company admins receive notifications
*/
public function platformInquiry(Business $buyerBusiness, Business $sellerBusiness, string $inquiryMessage): void
{
// RULE 1: Get sales reps for this buyer
$salesReps = $this->getSalesRepsForBuyer($buyerBusiness);
// RULE 2: Get custom email addresses
$customEmails = $this->parseEmailList($sellerBusiness->platform_inquiry_email_notifications);
// Collect recipients
$recipients = [];
// Add sales reps (ALWAYS)
foreach ($salesReps as $salesRep) {
$recipients[] = $salesRep->email;
}
// Add custom emails (ALWAYS if configured)
foreach ($customEmails as $email) {
$recipients[] = $email;
}
// RULE 3: If no recipients yet, send to company admins
if (empty($recipients)) {
$admins = $this->getCompanyAdmins($sellerBusiness);
foreach ($admins as $admin) {
$recipients[] = $admin->email;
}
}
// Remove duplicates
$recipients = array_unique($recipients);
// Send emails
foreach ($recipients as $email) {
// TODO: Create PlatformInquiryMail class
// Mail::to($email)->send(new PlatformInquiryMail($buyerBusiness, $inquiryMessage));
}
}
/**
* LOW INVENTORY EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'low_inventory_email_notifications' when inventory is low
* 2. No conditional logic - straightforward notification
*/
public function lowInventory(Business $sellerBusiness, $product, int $currentQuantity, int $threshold): void
{
// RULE 1: Get email addresses from settings
$emails = $this->parseEmailList($sellerBusiness->low_inventory_email_notifications);
if (empty($emails)) {
return; // No emails configured
}
// Send emails to all configured addresses
foreach ($emails as $email) {
// TODO: Create LowInventoryMail class
// Mail::to($email)->send(new LowInventoryMail($product, $currentQuantity, $threshold));
}
}
/**
* CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'certified_seller_status_email_notifications' when status changes
* 2. No conditional logic - straightforward notification
*/
public function certifiedSellerStatusChanged(Business $sellerBusiness, string $oldStatus, string $newStatus): void
{
// RULE 1: Get email addresses from settings
$emails = $this->parseEmailList($sellerBusiness->certified_seller_status_email_notifications);
if (empty($emails)) {
return; // No emails configured
}
// Send emails to all configured addresses
foreach ($emails as $email) {
// TODO: Create CertifiedSellerStatusChangedMail class
// Mail::to($email)->send(new CertifiedSellerStatusChangedMail($sellerBusiness, $oldStatus, $newStatus));
}
}

58
app/Traits/HasHashid.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace App\Traits;
trait HasHashid
{
/**
* Boot the trait - automatically generate hashid on creation
*/
protected static function bootHasHashid(): void
{
static::creating(function ($model) {
if (empty($model->hashid)) {
$model->hashid = $model->generateHashid();
}
});
}
/**
* Generate a unique hashid in NNLLN format
* Example: 26bf7, 83jk2, 45mn9
* Excludes: 0, o, l, i to prevent confusion
*/
public function generateHashid(): string
{
$numbers = '123456789'; // Exclude 0
$letters = 'abcdefghjkmnpqrstuvwxyz'; // Exclude i, l, o
do {
$hashid = $numbers[rand(0, strlen($numbers) - 1)]
.$numbers[rand(0, strlen($numbers) - 1)]
.$letters[rand(0, strlen($letters) - 1)]
.$letters[rand(0, strlen($letters) - 1)]
.$numbers[rand(0, strlen($numbers) - 1)];
// Check if this hashid already exists
$exists = static::where('hashid', $hashid)->exists();
} while ($exists);
return $hashid;
}
/**
* Get the route key for the model (use hashid instead of id)
*/
public function getRouteKeyName(): string
{
return 'hashid';
}
/**
* Scope query to find by hashid
*/
public function scopeByHashid($query, string $hashid)
{
return $query->where('hashid', $hashid);
}
}

View File

@@ -42,7 +42,8 @@
"Database\\Seeders\\": "database/seeders/"
},
"files": [
"app/helpers.php"
"app/helpers.php",
"app/Helpers/helpers.php"
]
},
"autoload-dev": {

View File

@@ -60,6 +60,18 @@ return [
'report' => false,
],
'minio' => [
'driver' => 's3',
'key' => env('MINIO_ACCESS_KEY'),
'secret' => env('MINIO_SECRET_KEY'),
'region' => env('MINIO_REGION', 'us-east-1'),
'bucket' => env('MINIO_BUCKET'),
'endpoint' => env('MINIO_ENDPOINT'),
'use_path_style_endpoint' => true,
'throw' => false,
'report' => false,
],
],
/*

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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('brands', function (Blueprint $table) {
// Physical address fields
$table->string('address')->nullable()->after('website_url');
$table->string('unit_number', 50)->nullable()->after('address');
$table->string('city', 100)->nullable()->after('unit_number');
$table->string('state', 2)->nullable()->after('city');
$table->string('zip_code', 10)->nullable()->after('state');
$table->string('phone', 20)->nullable()->after('zip_code');
// Extended descriptions
$table->text('long_description')->nullable()->after('description');
// Banner image
$table->string('banner_path')->nullable()->after('logo_path');
// Additional social media
$table->string('youtube_url')->nullable()->after('twitter_handle');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->dropColumn([
'address',
'unit_number',
'city',
'state',
'zip_code',
'phone',
'long_description',
'banner_path',
'youtube_url',
]);
});
}
};

View File

@@ -0,0 +1,42 @@
<?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('brands', function (Blueprint $table) {
$table->string('hashid', 5)->nullable()->unique()->after('id');
$table->index('hashid');
});
// Generate hashids for existing brands
$brands = \App\Models\Brand::all();
foreach ($brands as $brand) {
$brand->hashid = $brand->generateHashid();
$brand->save();
}
// Make hashid non-nullable after populating existing records
Schema::table('brands', function (Blueprint $table) {
$table->string('hashid', 5)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->dropIndex(['hashid']);
$table->dropColumn('hashid');
});
}
};

View File

@@ -0,0 +1,42 @@
<?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('products', function (Blueprint $table) {
$table->string('hashid', 5)->nullable()->unique()->after('id');
$table->index('hashid');
});
// Generate hashids for existing products
$products = \App\Models\Product::all();
foreach ($products as $product) {
$product->hashid = $product->generateHashid();
$product->save();
}
// Make hashid non-nullable after populating existing records
Schema::table('products', function (Blueprint $table) {
$table->string('hashid', 5)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropIndex(['hashid']);
$table->dropColumn('hashid');
});
}
};

View File

@@ -0,0 +1,42 @@
<?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('contacts', function (Blueprint $table) {
$table->string('hashid', 5)->nullable()->unique()->after('id');
$table->index('hashid');
});
// Generate hashids for existing contacts
$contacts = \App\Models\Contact::all();
foreach ($contacts as $contact) {
$contact->hashid = $contact->generateHashid();
$contact->save();
}
// Make hashid non-nullable after populating existing records
Schema::table('contacts', function (Blueprint $table) {
$table->string('hashid', 5)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contacts', function (Blueprint $table) {
$table->dropIndex(['hashid']);
$table->dropColumn('hashid');
});
}
};

View File

@@ -0,0 +1,42 @@
<?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('components', function (Blueprint $table) {
$table->string('hashid', 5)->nullable()->unique()->after('id');
$table->index('hashid');
});
// Generate hashids for existing components
$components = \App\Models\Component::all();
foreach ($components as $component) {
$component->hashid = $component->generateHashid();
$component->save();
}
// Make hashid non-nullable after populating existing records
Schema::table('components', function (Blueprint $table) {
$table->string('hashid', 5)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('components', function (Blueprint $table) {
$table->dropIndex(['hashid']);
$table->dropColumn('hashid');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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('businesses', function (Blueprint $table) {
$table->string('banner_path')->nullable()->after('logo_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->dropColumn('banner_path');
});
}
};

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_events', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index();
// Event classification
$table->string('event_type', 50)->index();
$table->string('event_category', 50)->nullable();
$table->string('event_action', 100)->nullable();
// Subject references
$table->unsignedBigInteger('subject_id')->nullable();
$table->string('subject_type', 50)->nullable();
// User/Session tracking
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('session_id', 100)->nullable()->index();
$table->string('fingerprint', 64)->nullable();
// Request metadata
$table->string('url', 500)->nullable();
$table->string('referrer', 500)->nullable();
$table->string('utm_source', 100)->nullable();
$table->string('utm_medium', 100)->nullable();
$table->string('utm_campaign', 100)->nullable();
$table->string('utm_content', 100)->nullable();
$table->string('utm_term', 100)->nullable();
// Device/Browser info
$table->string('user_agent', 500)->nullable();
$table->string('device_type', 20)->nullable();
$table->string('browser', 50)->nullable();
$table->string('os', 50)->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('country_code', 2)->nullable();
// Additional context
$table->json('metadata')->nullable();
$table->timestamp('created_at')->index();
// Composite indexes for performance - ALWAYS start with business_id
$table->index(['business_id', 'created_at'], 'idx_business_created');
$table->index(['business_id', 'event_type', 'created_at'], 'idx_business_type_created');
$table->index(['business_id', 'subject_type', 'subject_id', 'created_at'], 'idx_business_subject_created');
$table->index(['business_id', 'user_id', 'created_at'], 'idx_business_user_created');
// Foreign keys
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
});
DB::statement("COMMENT ON TABLE analytics_events IS 'Raw analytics event stream - business isolated'");
}
public function down(): void
{
Schema::dropIfExists('analytics_events');
}
};

View File

@@ -0,0 +1,60 @@
<?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('product_views', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index(); // Seller's business
$table->unsignedBigInteger('product_id')->index();
// Who viewed
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->unsignedBigInteger('buyer_business_id')->nullable()->index(); // Buyer's business
$table->string('session_id', 100)->nullable();
// View details
$table->timestamp('viewed_at')->index();
$table->integer('time_on_page')->nullable();
$table->integer('scroll_depth')->nullable();
// Interaction signals
$table->boolean('zoomed_image')->default(false);
$table->boolean('watched_video')->default(false);
$table->boolean('downloaded_spec')->default(false);
$table->boolean('added_to_cart')->default(false);
$table->boolean('added_to_wishlist')->default(false);
// Attribution
$table->string('source', 100)->nullable();
$table->string('referrer', 500)->nullable();
$table->string('utm_campaign', 100)->nullable();
// Device
$table->string('device_type', 20)->nullable();
$table->timestamps();
// Composite indexes - ALWAYS start with business_id
$table->index(['business_id', 'product_id', 'viewed_at'], 'idx_business_product_time');
$table->index(['business_id', 'user_id', 'viewed_at'], 'idx_business_user_time');
$table->index(['buyer_business_id', 'viewed_at'], 'idx_buyer_business_time');
// Foreign keys
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('buyer_business_id')->references('id')->on('businesses')->onDelete('set null');
});
}
public function down(): void
{
Schema::dropIfExists('product_views');
}
};

View File

@@ -0,0 +1,111 @@
<?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
{
// Email campaigns
Schema::create('email_campaigns', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index();
$table->string('name');
$table->string('subject');
$table->text('content')->nullable();
$table->string('status', 20)->default('draft');
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('sent_at')->nullable();
$table->integer('total_recipients')->default(0);
$table->integer('total_sent')->default(0);
$table->integer('total_delivered')->default(0);
$table->integer('total_opened')->default(0);
$table->integer('total_clicked')->default(0);
$table->integer('total_bounced')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['business_id', 'status', 'created_at'], 'idx_business_status_time');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
});
// Email interactions
Schema::create('email_interactions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index();
$table->unsignedBigInteger('campaign_id')->nullable()->index();
$table->unsignedBigInteger('recipient_user_id')->nullable()->index();
$table->string('recipient_email');
// Tracking token
$table->string('tracking_token', 64)->unique();
// Delivery
$table->timestamp('sent_at')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamp('bounced_at')->nullable();
$table->string('bounce_reason')->nullable();
// Opens
$table->timestamp('first_opened_at')->nullable();
$table->timestamp('last_opened_at')->nullable();
$table->integer('open_count')->default(0);
// Clicks
$table->timestamp('first_clicked_at')->nullable();
$table->timestamp('last_clicked_at')->nullable();
$table->integer('click_count')->default(0);
// Device/client
$table->string('email_client')->nullable();
$table->string('device_type')->nullable();
// Engagement
$table->integer('engagement_score')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['business_id', 'sent_at'], 'idx_business_sent');
$table->index(['business_id', 'campaign_id', 'sent_at'], 'idx_business_campaign_sent');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('campaign_id')->references('id')->on('email_campaigns')->onDelete('cascade');
$table->foreign('recipient_user_id')->references('id')->on('users')->onDelete('set null');
});
// Email clicks
Schema::create('email_clicks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index();
$table->unsignedBigInteger('email_interaction_id')->index();
$table->string('url', 500);
$table->string('link_identifier')->nullable();
$table->timestamp('clicked_at')->index();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent', 500)->nullable();
$table->index(['business_id', 'clicked_at'], 'idx_business_clicked');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('email_interaction_id')->references('id')->on('email_interactions')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('email_clicks');
Schema::dropIfExists('email_interactions');
Schema::dropIfExists('email_campaigns');
}
};

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
{
Schema::create('click_tracking', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index();
// What was clicked
$table->string('element_type', 50);
$table->unsignedBigInteger('element_id')->nullable();
$table->string('element_identifier')->nullable();
$table->text('element_text')->nullable();
// Where
$table->string('page_url', 500);
$table->string('page_type', 50)->nullable();
// Who
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('session_id', 100)->nullable();
// When
$table->timestamp('clicked_at')->index();
// Context
$table->string('position')->nullable();
$table->integer('position_index')->nullable();
$table->string('referrer', 500)->nullable();
$table->string('utm_campaign', 100)->nullable();
$table->string('device_type', 20)->nullable();
$table->json('metadata')->nullable();
$table->index(['business_id', 'element_type', 'clicked_at'], 'idx_click_business_element_time');
$table->index(['business_id', 'user_id', 'clicked_at'], 'idx_click_business_user_time');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
});
}
public function down(): void
{
Schema::dropIfExists('click_tracking');
}
};

View File

@@ -0,0 +1,139 @@
<?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
{
// User sessions
Schema::create('user_sessions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index();
$table->string('session_id', 100)->unique();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->unsignedBigInteger('buyer_business_id')->nullable()->index();
$table->timestamp('started_at')->index();
$table->timestamp('ended_at')->nullable();
$table->integer('duration_seconds')->nullable();
// Session stats
$table->integer('page_views')->default(0);
$table->integer('product_views')->default(0);
$table->integer('clicks')->default(0);
$table->integer('interactions')->default(0);
// Entry/exit
$table->string('landing_page', 500)->nullable();
$table->string('exit_page', 500)->nullable();
$table->string('referrer', 500)->nullable();
// Attribution
$table->string('utm_source', 100)->nullable();
$table->string('utm_medium', 100)->nullable();
$table->string('utm_campaign', 100)->nullable();
// Device
$table->string('device_type', 20)->nullable();
$table->string('browser', 50)->nullable();
$table->string('os', 50)->nullable();
$table->string('ip_address', 45)->nullable();
// Conversion
$table->boolean('converted')->default(false);
$table->decimal('conversion_value', 12, 2)->nullable();
$table->json('metadata')->nullable();
$table->index(['business_id', 'started_at'], 'idx_business_started');
$table->index(['business_id', 'user_id', 'started_at'], 'idx_business_user_started');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('buyer_business_id')->references('id')->on('businesses')->onDelete('set null');
});
// Intent signals
Schema::create('intent_signals', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index(); // Seller's business
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->unsignedBigInteger('buyer_business_id')->nullable()->index();
$table->string('session_id', 100)->nullable();
$table->string('signal_type', 50);
$table->integer('signal_strength')->default(1);
// What triggered it
$table->unsignedBigInteger('subject_id')->nullable();
$table->string('subject_type', 50)->nullable();
$table->text('description')->nullable();
$table->timestamp('detected_at')->index();
$table->json('metadata')->nullable();
$table->index(['business_id', 'detected_at'], 'idx_business_detected');
$table->index(['business_id', 'signal_type', 'detected_at'], 'idx_business_signal_detected');
$table->index(['buyer_business_id', 'detected_at'], 'idx_buyer_business_detected');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('buyer_business_id')->references('id')->on('businesses')->onDelete('set null');
});
// Buyer engagement scores
Schema::create('buyer_engagement_scores', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('business_id')->index(); // Seller's business
$table->unsignedBigInteger('buyer_business_id')->index();
// Score
$table->integer('score')->default(0);
$table->string('score_tier', 20)->nullable();
// Components
$table->integer('recency_score')->default(0);
$table->integer('frequency_score')->default(0);
$table->integer('depth_score')->default(0);
$table->integer('intent_score')->default(0);
// Time metrics
$table->timestamp('first_interaction_at')->nullable();
$table->timestamp('last_interaction_at')->nullable();
$table->integer('days_since_last_interaction')->nullable();
// Activity (30 days)
$table->integer('sessions_30d')->default(0);
$table->integer('page_views_30d')->default(0);
$table->integer('product_views_30d')->default(0);
// Purchase
$table->integer('total_orders')->default(0);
$table->decimal('total_revenue', 12, 2)->default(0);
$table->timestamp('last_order_at')->nullable();
$table->integer('days_since_last_order')->nullable();
$table->timestamp('calculated_at');
$table->timestamps();
$table->unique(['business_id', 'buyer_business_id'], 'unique_business_buyer');
$table->index(['business_id', 'score', 'calculated_at'], 'idx_business_score');
$table->foreign('business_id')->references('id')->on('businesses')->onDelete('cascade');
$table->foreign('buyer_business_id')->references('id')->on('businesses')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('buyer_engagement_scores');
Schema::dropIfExists('intent_signals');
Schema::dropIfExists('user_sessions');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* This migration doesn't change the schema - business_user.permissions already exists as JSON.
* This is just documentation of the available analytics permissions.
*/
public function up(): void
{
// Analytics permissions that can be added to business_user.permissions JSON:
$analyticsPermissions = [
'analytics.overview' => 'Access main analytics dashboard',
'analytics.products' => 'View product performance analytics',
'analytics.marketing' => 'View marketing and email analytics',
'analytics.sales' => 'View sales intelligence and pipeline',
'analytics.buyers' => 'View buyer intelligence and engagement',
'analytics.export' => 'Export analytics data',
];
// Store in a config or documentation table if needed
// For now, these will be checked via hasBusinessPermission() helper
}
public function down(): void
{
// Nothing to roll back
}
};

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
{
public function up(): void
{
// This creates a dedicated queue for analytics jobs
// Uses Laravel's default jobs table structure
if (! Schema::hasTable('jobs')) {
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
if (! Schema::hasTable('failed_jobs')) {
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
}
public function down(): void
{
Schema::dropIfExists('failed_jobs');
Schema::dropIfExists('jobs');
}
};

View File

@@ -0,0 +1,65 @@
<?php
namespace Database\Seeders;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Database\Seeder;
class CannabrandsHashFactorySeeder extends Seeder
{
/**
* Run the database seeds.
*
* Seed Cannabrands business (seller) and Hash Factory brand
*/
public function run(): void
{
// Find or create Cannabrands business
$cannabrands = Business::firstOrCreate(
['slug' => 'cannabrands'],
[
'name' => 'Cannabrands',
'type' => 'seller',
'business_type' => 'brand',
'description' => 'Premium Arizona cannabis brand',
'is_active' => true,
'status' => 'approved',
'approved_at' => now(),
]
);
$this->command->info("Cannabrands business: {$cannabrands->name} (ID: {$cannabrands->id})");
// Find or create Hash Factory brand under Cannabrands
$hashFactory = Brand::firstOrCreate(
[
'business_id' => $cannabrands->id,
'slug' => 'hash-factory',
],
[
'name' => 'Hash Factory',
'description' => 'Premium solventless concentrates crafted with precision',
'long_description' => 'Hash Factory produces top-tier solventless concentrates including live hash rosin. Each product is made with precision and care, sustainably sourced and responsibly packaged by Arizonans. Experience pure, cold-pressed excellence with every dab.',
'tagline' => 'Crafting Excellence, One Dab at a Time',
'website_url' => 'https://hashfactory.com',
'address' => '3120 W Carefree Hwy',
'unit_number' => 'Ste 1-774',
'city' => 'Phoenix',
'state' => 'AZ',
'zip_code' => '85086',
'phone' => '(480) 847-3200',
'instagram_handle' => 'hashfactory',
'is_active' => true,
'is_public' => true,
'is_featured' => true,
'sort_order' => 1,
]
);
$this->command->info("Hash Factory brand: {$hashFactory->name} (ID: {$hashFactory->id})");
$this->command->info("Business ID: {$cannabrands->id} - Use this ID to associate users with Cannabrands");
$this->command->info('Cannabrands and Hash Factory seeded successfully!');
}
}

View File

@@ -38,6 +38,22 @@ class RoleSeeder extends Seeder
'guard_name' => 'web',
]);
Role::firstOrCreate(['name' => 'company-sales'], [
'guard_name' => 'web',
]);
Role::firstOrCreate(['name' => 'company-accounting'], [
'guard_name' => 'web',
]);
Role::firstOrCreate(['name' => 'company-manufacturing'], [
'guard_name' => 'web',
]);
Role::firstOrCreate(['name' => 'company-processing'], [
'guard_name' => 'web',
]);
// Buyer (Dispensary/Retailer) Roles
Role::firstOrCreate(['name' => 'buyer-owner'], [
'guard_name' => 'web',
@@ -51,15 +67,19 @@ class RoleSeeder extends Seeder
'guard_name' => 'web',
]);
$this->command->info('Created 9 roles:');
$this->command->info('Created 16 roles:');
$this->command->info(' - Super Admin (Platform admin)');
$this->command->info(' - buyer (Simple buyer role)');
$this->command->info(' - seller (Simple seller role)');
$this->command->info(' - company-owner (Seller owner)');
$this->command->info(' - company-manager (Seller manager)');
$this->command->info(' - company-user (Seller user)');
$this->command->info(' - buyer-owner (Buyer owner)');
$this->command->info(' - buyer-manager (Buyer manager)');
$this->command->info(' - buyer-user (Buyer user)');
$this->command->info(' - company-owner (Owner - can be used for buyer or seller)');
$this->command->info(' - company-manager (Manager - can be used for buyer or seller)');
$this->command->info(' - company-user (Staff - can be used for buyer or seller)');
$this->command->info(' - company-sales (Sales - can be used for buyer or seller)');
$this->command->info(' - company-accounting (Accounting - can be used for buyer or seller)');
$this->command->info(' - company-manufacturing (Manufacturing - can be used for buyer or seller)');
$this->command->info(' - company-processing (Processing - can be used for buyer or seller)');
$this->command->info(' - buyer-owner (Buyer owner - legacy)');
$this->command->info(' - buyer-manager (Buyer manager - legacy)');
$this->command->info(' - buyer-user (Buyer user - legacy)');
}
}

View File

@@ -0,0 +1,233 @@
/**
* Analytics Tracker - Client-side tracking for Cannabrands Analytics
*/
class AnalyticsTracker {
constructor() {
this.sessionId = this.getOrCreateSessionId();
this.pageLoadTime = Date.now();
this.scrollDepth = 0;
this.setupListeners();
}
/**
* Get or create session ID
*/
getOrCreateSessionId() {
let sessionId = sessionStorage.getItem('analytics_session_id');
if (!sessionId) {
sessionId = this.generateUUID();
sessionStorage.setItem('analytics_session_id', sessionId);
}
return sessionId;
}
/**
* Generate UUID for session
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Setup event listeners
*/
setupListeners() {
// Track scroll depth
let maxScroll = 0;
window.addEventListener('scroll', () => {
const scrollPercent = Math.round((window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100);
if (scrollPercent > maxScroll) {
maxScroll = scrollPercent;
this.scrollDepth = maxScroll;
}
});
// Track page unload (send time on page)
window.addEventListener('beforeunload', () => {
this.trackPageUnload();
});
// Track click events
document.addEventListener('click', (e) => {
this.handleClick(e);
}, true);
}
/**
* Track product view
*/
trackProductView(productId, signals = {}) {
this.sendEvent('product_view', {
product_id: productId,
session_id: this.sessionId,
signals: signals
});
}
/**
* Track product engagement signals
*/
trackProductSignal(productId, signalType, data = {}) {
this.sendEvent('product_signal', {
product_id: productId,
signal_type: signalType,
session_id: this.sessionId,
...data
});
}
/**
* Track generic click
*/
trackClick(elementType, elementId, elementLabel, url = null, metadata = {}) {
this.sendEvent('click', {
element_type: elementType,
element_id: elementId,
element_label: elementLabel,
url: url,
session_id: this.sessionId,
metadata: metadata
});
}
/**
* Handle click events
*/
handleClick(e) {
const target = e.target.closest('[data-track-click]');
if (!target) return;
const elementType = target.dataset.trackClick;
const elementId = target.dataset.trackId || null;
const elementLabel = target.dataset.trackLabel || target.textContent.trim();
const url = target.href || target.dataset.trackUrl || null;
this.trackClick(elementType, elementId, elementLabel, url, {
page_url: window.location.href,
timestamp: Date.now()
});
}
/**
* Track page unload
*/
trackPageUnload() {
const timeOnPage = Math.round((Date.now() - this.pageLoadTime) / 1000);
// Use sendBeacon for reliable delivery on page unload
const data = {
event_type: 'page_exit',
session_id: this.sessionId,
time_on_page: timeOnPage,
scroll_depth: this.scrollDepth,
page_url: window.location.href
};
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/track', JSON.stringify(data));
}
}
/**
* Send event to backend
*/
async sendEvent(eventType, data) {
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
'Accept': 'application/json'
},
body: JSON.stringify({
event_type: eventType,
...data,
timestamp: Date.now()
})
});
} catch (error) {
console.error('Analytics tracking error:', error);
}
}
}
/**
* Product Page Tracker - Enhanced tracking for product detail pages
*/
class ProductPageTracker extends AnalyticsTracker {
constructor(productId) {
super();
this.productId = productId;
this.hasZoomedImage = false;
this.hasWatchedVideo = false;
this.hasDownloadedSpec = false;
this.setupProductListeners();
this.trackProductView();
}
trackProductView() {
super.trackProductView(this.productId, {
referrer: document.referrer,
utm_campaign: new URLSearchParams(window.location.search).get('utm_campaign')
});
}
setupProductListeners() {
// Track image zoom
document.querySelectorAll('[data-product-image-zoom]').forEach(el => {
el.addEventListener('click', () => {
if (!this.hasZoomedImage) {
this.hasZoomedImage = true;
this.trackProductSignal(this.productId, 'zoomed_image');
}
});
});
// Track video play
document.querySelectorAll('video[data-product-video]').forEach(video => {
video.addEventListener('play', () => {
if (!this.hasWatchedVideo) {
this.hasWatchedVideo = true;
this.trackProductSignal(this.productId, 'watched_video');
}
});
});
// Track spec download
document.querySelectorAll('[data-product-spec-download]').forEach(el => {
el.addEventListener('click', () => {
if (!this.hasDownloadedSpec) {
this.hasDownloadedSpec = true;
this.trackProductSignal(this.productId, 'downloaded_spec');
}
});
});
// Track add to cart
document.querySelectorAll('[data-product-add-cart]').forEach(el => {
el.addEventListener('click', () => {
this.trackProductSignal(this.productId, 'added_to_cart');
});
});
// Track add to wishlist
document.querySelectorAll('[data-product-add-wishlist]').forEach(el => {
el.addEventListener('click', () => {
this.trackProductSignal(this.productId, 'added_to_wishlist');
});
});
}
}
// Initialize global tracker
window.analyticsTracker = new AnalyticsTracker();
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { AnalyticsTracker, ProductPageTracker };
}

View File

@@ -0,0 +1,146 @@
/**
* Reverb Analytics Listener - Real-time analytics notifications
*/
class ReverbAnalyticsListener {
constructor(businessId) {
this.businessId = businessId;
this.notifications = [];
this.setupReverb();
}
/**
* Setup Reverb connection
*/
setupReverb() {
if (typeof window.Echo === 'undefined') {
console.warn('Laravel Echo not initialized. Real-time analytics disabled.');
return;
}
// Subscribe to business analytics channel
window.Echo.channel(`business.${this.businessId}.analytics`)
.listen('.high-intent-buyer-detected', (event) => {
this.handleHighIntentBuyer(event);
});
console.log(`Subscribed to analytics channel for business ${this.businessId}`);
}
/**
* Handle high-intent buyer detection
*/
handleHighIntentBuyer(event) {
console.log('High-intent buyer detected:', event);
// Show notification
this.showNotification({
title: 'High-Intent Buyer Detected!',
message: `${event.buyer_business_name} showing ${event.signal_type} signals`,
type: 'success',
data: event
});
// Add to notifications array
this.notifications.unshift(event);
// Emit custom event for UI updates
window.dispatchEvent(new CustomEvent('analytics:high-intent-buyer', {
detail: event
}));
// Update UI badge if exists
this.updateNotificationBadge();
}
/**
* Show toast notification
*/
showNotification({ title, message, type = 'info', data = null }) {
// Check if toast container exists
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast toast-top toast-end z-50';
document.body.appendChild(container);
}
// Create toast
const toast = document.createElement('div');
toast.className = `alert alert-${type} shadow-lg cursor-pointer`;
toast.innerHTML = `
<div>
<span class="icon-[lucide--zap] size-5"></span>
<div>
<div class="font-bold">${title}</div>
<div class="text-sm">${message}</div>
</div>
</div>
`;
// Add click handler to view details
if (data?.buyer_business_id) {
toast.addEventListener('click', () => {
window.location.href = `/s/${this.getBusinessSlug()}/analytics/buyers/${data.buyer_business_id}`;
});
}
container.appendChild(toast);
// Auto-remove after 10 seconds
setTimeout(() => {
toast.classList.add('animate-fade-out');
setTimeout(() => toast.remove(), 300);
}, 10000);
}
/**
* Update notification badge
*/
updateNotificationBadge() {
const badge = document.getElementById('analytics-notification-badge');
if (badge) {
badge.textContent = this.notifications.length;
badge.classList.remove('hidden');
}
}
/**
* Get business slug from URL
*/
getBusinessSlug() {
const pathParts = window.location.pathname.split('/');
return pathParts[2]; // Assuming /s/{business}/...
}
/**
* Get recent notifications
*/
getRecentNotifications(limit = 10) {
return this.notifications.slice(0, limit);
}
/**
* Clear notifications
*/
clearNotifications() {
this.notifications = [];
this.updateNotificationBadge();
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Get business ID from meta tag or data attribute
const businessId = document.querySelector('meta[name="business-id"]')?.content;
if (businessId) {
window.reverbAnalyticsListener = new ReverbAnalyticsListener(businessId);
console.log('Reverb Analytics Listener initialized');
}
});
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = ReverbAnalyticsListener;
}

View File

@@ -101,22 +101,32 @@
</div>
@endif
<!-- Status Badge -->
<div class="card-actions justify-end mt-4">
@if($user->status === 'approved')
<span class="badge badge-success gap-2">
<span class="icon-[lucide--check] size-3"></span>
Active
</span>
@elseif($user->status === 'pending')
<span class="badge badge-warning gap-2">
<span class="icon-[lucide--clock] size-3"></span>
Pending
</span>
@else
<span class="badge badge-ghost gap-2">
{{ ucfirst($user->status ?? 'Unknown') }}
</span>
<!-- Actions -->
<div class="card-actions justify-between mt-4">
<div>
@if($user->status === 'approved')
<span class="badge badge-success gap-2">
<span class="icon-[lucide--check] size-3"></span>
Active
</span>
@elseif($user->status === 'pending')
<span class="badge badge-warning gap-2">
<span class="icon-[lucide--clock] size-3"></span>
Pending
</span>
@else
<span class="badge badge-ghost gap-2">
{{ ucfirst($user->status ?? 'Unknown') }}
</span>
@endif
</div>
@if(auth()->user()->user_type === 'seller' && $business->owner_user_id === auth()->id())
<button onclick="openPermissionsModal({{ $user->id }}, '{{ $user->first_name }} {{ $user->last_name }}', {{ json_encode($user->pivot->permissions ?? []) }})"
class="btn btn-sm btn-ghost">
<span class="icon-[lucide--shield] size-4"></span>
Permissions
</button>
@endif
</div>
</div>
@@ -136,4 +146,115 @@
</div>
</div>
@endif
<!-- Permissions Modal -->
<dialog id="permissions-modal" class="modal">
<div class="modal-box max-w-2xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-4">
<span class="icon-[lucide--shield] size-5 inline-block"></span>
Manage Permissions: <span id="modal-user-name"></span>
</h3>
<div class="divider"></div>
<form id="permissions-form">
<input type="hidden" id="user-id" name="user_id">
<div class="space-y-4">
<div>
<h4 class="font-semibold mb-3 flex items-center gap-2">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
Analytics Permissions
</h4>
<div class="grid grid-cols-1 gap-2">
@foreach($analyticsPermissions as $key => $description)
<label class="flex items-start gap-3 p-3 rounded-lg hover:bg-base-200 cursor-pointer">
<input type="checkbox"
name="permissions[]"
value="{{ $key }}"
class="checkbox checkbox-primary mt-0.5">
<div class="flex-1">
<div class="font-medium">{{ ucwords(str_replace(['analytics.', '_'], ['', ' '], $key)) }}</div>
<div class="text-sm text-base-content/60">{{ $description }}</div>
</div>
</label>
@endforeach
</div>
</div>
</div>
<div class="modal-action">
<button type="button" onclick="document.getElementById('permissions-modal').close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--save] size-4"></span>
Save Permissions
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@endsection
@push('scripts')
<script>
let currentUserId = null;
function openPermissionsModal(userId, userName, currentPermissions) {
currentUserId = userId;
document.getElementById('modal-user-name').textContent = userName;
document.getElementById('user-id').value = userId;
// Uncheck all checkboxes first
document.querySelectorAll('#permissions-form input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
// Check current permissions
if (Array.isArray(currentPermissions)) {
currentPermissions.forEach(permission => {
const checkbox = document.querySelector(`input[value="${permission}"]`);
if (checkbox) checkbox.checked = true;
});
}
document.getElementById('permissions-modal').showModal();
}
document.getElementById('permissions-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const permissions = formData.getAll('permissions[]');
try {
const response = await fetch(`{{ route('seller.business.users.permissions', [$business->slug, ':userId']) }}`.replace(':userId', currentUserId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ permissions })
});
const data = await response.json();
if (data.success) {
document.getElementById('permissions-modal').close();
window.location.reload();
} else {
alert('Error: ' + (data.message || 'Failed to update permissions'));
}
} catch (error) {
console.error('Error updating permissions:', error);
alert('Failed to update permissions. Please try again.');
}
});
</script>
@endpush

View File

@@ -52,7 +52,8 @@
<div class="card-body">
<div class="grid grid-cols-4 gap-2">
@foreach($galleryImages as $image)
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-75">
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-75"
data-product-image-zoom>
<img src="{{ asset('storage/' . $image->path) }}" alt="{{ $product->name }}" class="w-full h-full object-cover">
</div>
@endforeach
@@ -222,7 +223,9 @@
x-ref="quantityInput">
</div>
<button type="submit" class="btn btn-primary btn-lg w-full">
<button type="submit"
class="btn btn-primary btn-lg w-full"
data-product-add-cart>
<span class="icon-[lucide--shopping-cart] size-5"></span>
Add to Cart
</button>
@@ -340,7 +343,10 @@
</td>
<td>
@if($lab->report_url)
<a href="{{ $lab->report_url }}" target="_blank" class="btn btn-ghost btn-sm">
<a href="{{ $lab->report_url }}"
target="_blank"
class="btn btn-ghost btn-sm"
data-product-spec-download>
<span class="icon-[lucide--external-link] size-4"></span>
View Report
</a>
@@ -383,7 +389,11 @@
<div class="card-body">
<h4 class="card-title text-base">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}" class="hover:text-primary">
<a href="{{ route('buyer.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>
@@ -393,7 +403,11 @@
</div>
<div class="card-actions justify-end mt-2">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}" class="btn btn-primary btn-sm">
<a href="{{ route('buyer.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>
@@ -408,6 +422,12 @@
@push('scripts')
<script>
// Initialize Product Page Analytics Tracker
if (typeof ProductPageTracker !== 'undefined') {
const productTracker = new ProductPageTracker({{ $product->id }});
console.log('Product analytics initialized for product ID: {{ $product->id }}');
}
// Alpine.js component for batch selection
function batchSelection() {
return {

View File

@@ -161,14 +161,14 @@
<!-- Version Info Section -->
<div class="mx-2 mb-3 px-3 text-xs text-center text-base-content/50">
<p class="mb-0.5">{{ config('version.company.name') }} hub</p>
<p class="font-mono mb-0.5">
<p class="mb-0.5" style="font-family: 'Courier New', monospace;">
@if($appVersion === 'dev')
<span class="text-yellow-500 font-semibold">DEV</span> sha-{{ $appCommit }}
@else
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
<p>&copy; {{ date('Y') }} {{ config('version.company.name') }}.com, {{ config('version.company.suffix') }}</p>
<p>&copy; {{ date('Y') }} Made with <span class="text-error">&hearts;</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover text-xs">Creationshop</a></p>
</div>
<div class="dropdown dropdown-top dropdown-end w-full">

View File

@@ -45,51 +45,88 @@
<x-brand-switcher />
<div class="mb-3 space-y-0.5 px-2.5" x-data="{
menuDashboard: $persist(true).as('sidebar-menu-dashboard'),
menuAnalytics: $persist(true).as('sidebar-menu-analytics'),
menuOrders: $persist(false).as('sidebar-menu-orders'),
menuInvoices: $persist(false).as('sidebar-menu-invoices'),
menuInventory: $persist(true).as('sidebar-menu-inventory'),
menuCustomers: $persist(false).as('sidebar-menu-customers'),
menuFleet: $persist(true).as('sidebar-menu-fleet'),
menuBusiness: $persist(true).as('sidebar-menu-business')
menuCompany: $persist(true).as('sidebar-menu-company'),
menuSettings: $persist(true).as('sidebar-menu-settings')
}">
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Overview</p>
<!-- Dashboard - single item -->
@if($sidebarBusiness)
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ $sidebarBusiness ? route('seller.business.dashboard', $sidebarBusiness->slug) : route('seller.dashboard') }}">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
<span class="grow">Dashboard</span>
</a>
@else
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ route('seller.dashboard') }}">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
<span class="grow">Dashboard</span>
</a>
@endif
<!-- Analytics - parent with subsections -->
@if($sidebarBusiness && hasBusinessPermission('analytics.overview'))
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuDashboard" />
x-model="menuAnalytics" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
<span class="grow">Dashboard</span>
<span class="icon-[lucide--line-chart] size-4"></span>
<span class="grow">Analytics</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ $sidebarBusiness ? route('seller.business.dashboard', $sidebarBusiness->slug) : route('seller.dashboard') }}">
@if(hasBusinessPermission('analytics.overview'))
<a class="menu-item {{ request()->routeIs('seller.business.analytics.index') ? 'active' : '' }}" href="{{ route('seller.business.analytics.index', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
@if($sidebarBusiness)
<a class="menu-item {{ request()->routeIs('seller.business.analytics.*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.index', $sidebarBusiness->slug) }}">
<span class="grow">Analytics</span>
@endif
@if(hasBusinessPermission('analytics.products'))
<a class="menu-item {{ request()->routeIs('seller.business.analytics.products*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.products', $sidebarBusiness->slug) }}">
<span class="grow">Products</span>
</a>
<a class="menu-item" href="#">
<span class="grow">Reports</span>
<span class="badge badge-sm badge-primary">Soon</span>
@endif
@if(hasBusinessPermission('analytics.marketing'))
<a class="menu-item {{ request()->routeIs('seller.business.analytics.marketing*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.marketing', $sidebarBusiness->slug) }}">
<span class="grow">Marketing</span>
</a>
@else
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Analytics</span>
@endif
@if(hasBusinessPermission('analytics.sales'))
<a class="menu-item {{ request()->routeIs('seller.business.analytics.sales*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.sales', $sidebarBusiness->slug) }}">
<span class="grow">Sales</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Reports</span>
@endif
@if(hasBusinessPermission('analytics.buyers'))
<a class="menu-item {{ request()->routeIs('seller.business.analytics.buyers*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.buyers', $sidebarBusiness->slug) }}">
<span class="grow">Buyers</span>
</a>
@endif
</div>
</div>
</div>
@endif
<!-- Reports - future section -->
@if($sidebarBusiness)
<a class="menu-item" href="#">
<span class="icon-[lucide--file-text] size-4"></span>
<span class="grow">Reports</span>
<span class="badge badge-sm badge-primary">Soon</span>
</a>
@endif
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Ecommerce</p>
<div class="group collapse">
@@ -221,7 +258,127 @@
</div>
</div>
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Operations</p>
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Business</p>
@if(auth()->user()?->hasRole('super-admin'))
<a class="menu-item {{ request()->routeIs('filament.admin.*') ? 'active' : '' }}" href="{{ route('filament.admin.pages.dashboard') }}">
<span class="icon-[lucide--shield-check] size-4"></span>
<span class="grow">Admin Panel</span>
</a>
@endif
<!-- Business Section -->
<div class="group collapse overflow-visible">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuCompany" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--building-2] size-4"></span>
<span class="grow">Business</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0 overflow-visible">
<div class="mt-0.5 space-y-0.5 pb-1">
<a class="menu-item {{ request()->routeIs('seller.business.settings.company-information') ? 'active' : '' }}" href="{{ route('seller.business.settings.company-information', $sidebarBusiness->slug) }}">
<span class="grow">Business Information</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.users') ? 'active' : '' }}" href="{{ route('seller.business.settings.users', $sidebarBusiness->slug) }}">
<span class="grow">Users</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.manage-licenses') ? 'active' : '' }}" href="{{ route('seller.business.settings.manage-licenses', $sidebarBusiness->slug) }}">
<span class="grow">Manage Licenses</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.plans-and-billing') ? 'active' : '' }}" href="{{ route('seller.business.settings.plans-and-billing', $sidebarBusiness->slug) }}">
<span class="grow">Subscriptions & Billing</span>
</a>
</div>
</div>
@else
<div class="collapse-content ms-6.5 !p-0 overflow-visible">
<div class="mt-0.5 space-y-0.5 pb-1">
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Business Information</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Users</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Manage Licenses</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Subscriptions & Billing</span>
</a>
</div>
</div>
@endif
</div>
<!-- Settings Section -->
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuSettings" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--settings] size-4"></span>
<span class="grow">Settings</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.settings.categories.*') ? 'active' : '' }}" href="{{ route('seller.business.settings.categories.index', $sidebarBusiness->slug) }}">
<span class="grow">Categories</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.orders') ? 'active' : '' }}" href="{{ route('seller.business.settings.orders', $sidebarBusiness->slug) }}">
<span class="grow">Orders</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.invoices') ? 'active' : '' }}" href="{{ route('seller.business.settings.invoices', $sidebarBusiness->slug) }}">
<span class="grow">Invoices</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.brands') ? 'active' : '' }}" href="{{ route('seller.business.settings.brands', $sidebarBusiness->slug) }}">
<span class="grow">Brands</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.notifications') ? 'active' : '' }}" href="{{ route('seller.business.settings.notifications', $sidebarBusiness->slug) }}">
<span class="grow">Notifications</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.reports') ? 'active' : '' }}" href="{{ route('seller.business.settings.reports', $sidebarBusiness->slug) }}">
<span class="grow">Reports</span>
</a>
</div>
</div>
@else
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Categories</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Orders</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Invoices</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Brands</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Notifications</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Reports</span>
</a>
</div>
</div>
@endif
</div>
<!-- Fleet Management Section -->
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
@@ -254,99 +411,6 @@
@endif
</div>
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Business</p>
@if(auth()->user()?->hasRole('super-admin'))
<a class="menu-item {{ request()->routeIs('filament.admin.*') ? 'active' : '' }}" href="{{ route('filament.admin.pages.dashboard') }}">
<span class="icon-[lucide--shield-check] size-4"></span>
<span class="grow">Admin Panel</span>
</a>
@endif
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuBusiness" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--building-2] size-4"></span>
<span class="grow">Company</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.settings.company-information') ? 'active' : '' }}" href="{{ route('seller.business.settings.company-information', $sidebarBusiness->slug) }}">
<span class="grow">Company Information</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.users') ? 'active' : '' }}" href="{{ route('seller.business.settings.users', $sidebarBusiness->slug) }}">
<span class="grow">Users</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.orders') ? 'active' : '' }}" href="{{ route('seller.business.settings.orders', $sidebarBusiness->slug) }}">
<span class="grow">Orders</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.brands') ? 'active' : '' }}" href="{{ route('seller.business.settings.brands', $sidebarBusiness->slug) }}">
<span class="grow">Brands</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.payments') ? 'active' : '' }}" href="{{ route('seller.business.settings.payments', $sidebarBusiness->slug) }}">
<span class="grow">Payments</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.invoices') ? 'active' : '' }}" href="{{ route('seller.business.settings.invoices', $sidebarBusiness->slug) }}">
<span class="grow">Invoices</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.manage-licenses') ? 'active' : '' }}" href="{{ route('seller.business.settings.manage-licenses', $sidebarBusiness->slug) }}">
<span class="grow">Manage Licenses</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.plans-and-billing') ? 'active' : '' }}" href="{{ route('seller.business.settings.plans-and-billing', $sidebarBusiness->slug) }}">
<span class="grow">Plans and Billing</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.notifications') ? 'active' : '' }}" href="{{ route('seller.business.settings.notifications', $sidebarBusiness->slug) }}">
<span class="grow">Notifications</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.settings.reports') ? 'active' : '' }}" href="{{ route('seller.business.settings.reports', $sidebarBusiness->slug) }}">
<span class="grow">Reports</span>
</a>
</div>
</div>
@else
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Company Information</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Users</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Orders</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Brands</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Payments</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Invoices</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Manage Licenses</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Plans and Billing</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Notifications</span>
</a>
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
<span class="grow">Reports</span>
</a>
</div>
</div>
@endif
</div>
</div>
</div>
<div class="from-base-100/60 pointer-events-none absolute start-0 end-0 bottom-0 h-7 bg-gradient-to-t to-transparent"></div>
@@ -357,14 +421,14 @@
<!-- Version Info Section -->
<div class="mx-2 mb-3 px-3 text-xs text-center text-base-content/50">
<p class="mb-0.5">{{ config('version.company.name') }} hub</p>
<p class="font-mono mb-0.5">
<p class="mb-0.5" style="font-family: 'Courier New', monospace;">
@if($appVersion === 'dev')
<span class="text-yellow-500 font-semibold">DEV</span> sha-{{ $appCommit }}
@else
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
<p>&copy; {{ date('Y') }} {{ config('version.company.name') }}.com, {{ config('version.company.suffix') }}</p>
<p>&copy; {{ date('Y') }} Made with <span class="text-error">&hearts;</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover text-xs">Creationshop</a></p>
</div>
<div class="dropdown dropdown-top dropdown-end w-full">

View File

@@ -13,22 +13,6 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<!-- Remove spinner arrows from number inputs globally -->
<style>
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>
</head>
<body class="font-sans antialiased bg-base-100">
<script>
@@ -60,7 +44,7 @@
</main>
<footer class="text-center text-sm text-gray-400 py-4 bg-base-100">
<p>
&copy; {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
&copy; {{ date('Y') }} Made with <span class="text-error">&hearts;</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover">Creationshop</a> |
@if($appVersion === 'dev')
<span class="text-yellow-500 font-semibold">DEV</span>
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>

View File

@@ -406,6 +406,9 @@
</script>
<!-- Analytics Tracking -->
@include('partials.analytics')
<!-- Page-specific scripts -->
@stack('scripts')
</body>

View File

@@ -79,7 +79,7 @@
</div>
<footer class="text-center text-sm text-base-content/secondary py-4 bg-base-100">
<p>
&copy; {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
&copy; {{ date('Y') }} Made with <span class="text-error">&hearts;</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover">Creationshop</a> |
@if($appVersion === 'dev')
<span class="text-yellow-500 font-semibold">DEV</span>
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
@@ -90,5 +90,8 @@
</p>
</footer>
</div>
<!-- Analytics Tracking -->
@include('partials.analytics')
</body>
</html>

View File

@@ -0,0 +1,18 @@
{{-- Analytics Tracking Script --}}
{{-- Only load for authenticated buyers --}}
@if(auth()->check() && auth()->user()->user_type === 'buyer')
@php
$business = auth()->user()->businesses->first();
@endphp
@if($business)
{{-- Business ID meta tag for tracking --}}
<meta name="business-id" content="{{ $business->id }}">
{{-- Analytics Tracker Script --}}
<script src="{{ asset('js/analytics-tracker.js') }}"></script>
{{-- Optional: Page-specific tracking initialization --}}
@stack('analytics-init')
@endif
@endif

View File

@@ -0,0 +1,238 @@
{{-- Analytics Tracker - Include this file to automatically track page views, sessions, and engagement --}}
{{-- Usage: @include('partials.analytics-tracker') --}}
<script>
(function() {
'use strict';
// Configuration
const config = {
trackingEndpoint: '{{ route("analytics.track") }}',
sessionEndpoint: '{{ route("analytics.session") }}',
@if(isset($product))
productId: {{ $product->id ?? 'null' }},
@endif
csrf: '{{ csrf_token() }}',
enabled: {{ config('analytics.enabled', true) ? 'true' : 'false' }}
};
if (!config.enabled) return;
// Session tracking
let sessionStartTime = Date.now();
let pageStartTime = Date.now();
let scrollDepth = 0;
let maxScrollDepth = 0;
let engagementSignals = {
zoomed_image: false,
watched_video: false,
downloaded_spec: false,
added_to_cart: false,
added_to_wishlist: false
};
// Initialize session
function initSession() {
fetch(config.sessionEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': config.csrf,
'Accept': 'application/json'
},
credentials: 'same-origin'
}).catch(err => console.debug('Analytics session init failed:', err));
}
// Track page view
function trackPageView() {
const data = {
event_type: 'page_view',
url: window.location.href,
referrer: document.referrer,
title: document.title,
timestamp: new Date().toISOString()
};
sendAnalytics(data);
}
// Track scroll depth
function updateScrollDepth() {
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
scrollDepth = Math.round(((scrollTop + windowHeight) / documentHeight) * 100);
maxScrollDepth = Math.max(maxScrollDepth, scrollDepth);
}
// Track time on page and engagement when leaving
function trackEngagement() {
const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000); // seconds
@if(isset($product))
// Track product view with engagement signals
const data = {
event_type: 'product_view',
product_id: config.productId,
time_on_page: timeOnPage,
scroll_depth: maxScrollDepth,
...engagementSignals,
timestamp: new Date().toISOString()
};
@else
// Track generic page engagement
const data = {
event_type: 'page_engagement',
time_on_page: timeOnPage,
scroll_depth: maxScrollDepth,
timestamp: new Date().toISOString()
};
@endif
// Use sendBeacon for reliable tracking on page unload
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
navigator.sendBeacon(config.trackingEndpoint, blob);
} else {
sendAnalytics(data);
}
}
// Generic analytics sender
function sendAnalytics(data) {
fetch(config.trackingEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': config.csrf,
'Accept': 'application/json'
},
body: JSON.stringify(data),
credentials: 'same-origin',
keepalive: true
}).catch(err => console.debug('Analytics tracking failed:', err));
}
// Track clicks
function trackClick(elementType, elementId, elementLabel, url) {
const data = {
event_type: 'click',
element_type: elementType,
element_id: elementId,
element_label: elementLabel,
url: url,
timestamp: new Date().toISOString()
};
sendAnalytics(data);
}
// Auto-track important clicks
function setupClickTracking() {
// Track CTA buttons
document.querySelectorAll('[data-track-click]').forEach(el => {
el.addEventListener('click', function(e) {
const type = this.getAttribute('data-track-type') || 'button';
const id = this.getAttribute('data-track-id') || null;
const label = this.getAttribute('data-track-label') || this.textContent.trim();
const url = this.href || null;
trackClick(type, id, label, url);
});
});
@if(isset($product))
// Track product-specific engagement signals
// Image zoom
document.querySelectorAll('[data-action="zoom-image"]').forEach(el => {
el.addEventListener('click', function() {
engagementSignals.zoomed_image = true;
});
});
// Video play
document.querySelectorAll('video').forEach(video => {
video.addEventListener('play', function() {
engagementSignals.watched_video = true;
});
});
// Spec download
document.querySelectorAll('[data-action="download-spec"]').forEach(el => {
el.addEventListener('click', function() {
engagementSignals.downloaded_spec = true;
});
});
// Add to cart
document.querySelectorAll('[data-action="add-to-cart"]').forEach(el => {
el.addEventListener('click', function() {
engagementSignals.added_to_cart = true;
});
});
// Add to wishlist
document.querySelectorAll('[data-action="add-to-wishlist"]').forEach(el => {
el.addEventListener('click', function() {
engagementSignals.added_to_wishlist = true;
});
});
@endif
// Track external links
document.querySelectorAll('a[href^="http"]').forEach(link => {
if (!link.href.includes(window.location.hostname)) {
link.addEventListener('click', function(e) {
trackClick('external_link', null, this.textContent.trim(), this.href);
});
}
});
}
// Initialize tracking
function init() {
// Start session
initSession();
// Track initial page view
trackPageView();
// Setup scroll tracking
let scrollTimeout;
window.addEventListener('scroll', function() {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(updateScrollDepth, 100);
});
// Setup click tracking
setupClickTracking();
// Track engagement on page unload
window.addEventListener('beforeunload', trackEngagement);
// Also track on visibility change (mobile/tab switching)
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
trackEngagement();
}
});
// Track engagement periodically for long sessions (every 30 seconds)
setInterval(function() {
if (!document.hidden) {
trackEngagement();
}
}, 30000);
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>

View File

@@ -0,0 +1,24 @@
<x-seller-layout>
<div class="container mx-auto px-4 py-6">
<div class="mb-6">
<a href="{{ route('seller.business.analytics.buyers', $business->slug) }}" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Buyer Intelligence
</a>
</div>
<div class="mb-6">
<h1 class="text-3xl font-bold">{{ $buyer->name }}</h1>
<p class="text-base-content/60">Buyer Intelligence Detail</p>
</div>
<div class="grid gap-6">
<div class="card bg-base-100">
<div class="card-body">
<h2 class="card-title">Buyer Engagement</h2>
<p>Detailed buyer intelligence for {{ $buyer->name }} will be displayed here.</p>
</div>
</div>
</div>
</div>
</x-seller-layout>

View File

@@ -0,0 +1,228 @@
@extends('layouts.seller')
@section('title', 'Buyer Intelligence - ' . $business->name)
@section('content')
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Buyer Intelligence</h1>
<p class="text-base-content/70">Track buyer engagement and intent signals</p>
</div>
<div class="flex gap-2">
<select class="select select-bordered" onchange="window.location.href = '{{ route('seller.business.analytics.buyers', $business->slug) }}?period={{ $period }}&filter=' + this.value">
<option value="all" {{ $filter == 'all' ? 'selected' : '' }}>All Buyers</option>
<option value="high-value" {{ $filter == 'high-value' ? 'selected' : '' }}>High Value</option>
<option value="at-risk" {{ $filter == 'at-risk' ? 'selected' : '' }}>At Risk</option>
<option value="new" {{ $filter == 'new' ? 'selected' : '' }}>New</option>
</select>
<select class="select select-bordered" onchange="window.location.href = '{{ route('seller.business.analytics.buyers', $business->slug) }}?period=' + this.value + '&filter={{ $filter }}'">
<option value="7" {{ $period == 7 ? 'selected' : '' }}>Last 7 days</option>
<option value="30" {{ $period == 30 ? 'selected' : '' }}>Last 30 days</option>
<option value="60" {{ $period == 60 ? 'selected' : '' }}>Last 60 days</option>
<option value="90" {{ $period == 90 ? 'selected' : '' }}>Last 90 days</option>
</select>
</div>
</div>
<!-- Buyer Metrics -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Total Buyers</p>
<p class="text-2xl font-bold">{{ number_format($metrics['total_buyers']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Active</p>
<p class="text-2xl font-bold text-success">{{ number_format($metrics['active_buyers']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">High Value</p>
<p class="text-2xl font-bold text-primary">{{ number_format($metrics['high_value_buyers']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">At Risk</p>
<p class="text-2xl font-bold text-error">{{ number_format($metrics['at_risk_buyers']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">New</p>
<p class="text-2xl font-bold text-info">{{ number_format($metrics['new_buyers']) }}</p>
</div>
</div>
</div>
<!-- Score and Trend Distribution -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Engagement Score Distribution</h2>
<div id="score-distribution-chart" style="height: 250px;"></div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Engagement Trends</h2>
<div id="trend-distribution-chart" style="height: 250px;"></div>
</div>
</div>
</div>
<!-- Recent Intent Signals -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Recent High-Intent Signals</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Buyer</th>
<th>Signal Type</th>
<th>Strength</th>
<th>Detected</th>
</tr>
</thead>
<tbody>
@forelse($recentIntentSignals as $signal)
<tr>
<td>
<div class="font-medium">{{ $signal->buyerBusiness?->name ?? 'Unknown' }}</div>
@if($signal->user)
<div class="text-xs text-base-content/70">{{ $signal->user->name }}</div>
@endif
</td>
<td>
<span class="badge badge-outline">{{ ucwords(str_replace('_', ' ', $signal->signal_type)) }}</span>
</td>
<td>
@if($signal->signal_strength >= 75)
<span class="badge badge-error">{{ $signal->signal_strength }}</span>
@elseif($signal->signal_strength >= 50)
<span class="badge badge-warning">{{ $signal->signal_strength }}</span>
@else
<span class="badge badge-info">{{ $signal->signal_strength }}</span>
@endif
</td>
<td>{{ $signal->detected_at->diffForHumans() }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-base-content/50">No recent signals</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<!-- Buyer List -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Buyer Engagement Scores</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Business</th>
<th class="text-right">Total Score</th>
<th class="text-right">Sessions</th>
<th class="text-right">Product Views</th>
<th class="text-right">Orders</th>
<th>Trend</th>
<th>Last Active</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($buyers as $buyer)
<tr>
<td>
<div class="font-medium">{{ $buyer->buyerBusiness?->name ?? 'Unknown' }}</div>
</td>
<td class="text-right">
@if($buyer->total_score >= 70)
<span class="badge badge-success">{{ $buyer->total_score }}</span>
@elseif($buyer->total_score >= 40)
<span class="badge badge-warning">{{ $buyer->total_score }}</span>
@else
<span class="badge badge-error">{{ $buyer->total_score }}</span>
@endif
</td>
<td class="text-right">{{ number_format($buyer->total_sessions) }}</td>
<td class="text-right">{{ number_format($buyer->total_product_views) }}</td>
<td class="text-right">{{ number_format($buyer->total_orders) }}</td>
<td>
@if($buyer->engagement_trend === 'increasing')
<span class="badge badge-success">
<span class="icon-[lucide--trending-up] size-3"></span>
Increasing
</span>
@elseif($buyer->engagement_trend === 'declining')
<span class="badge badge-error">
<span class="icon-[lucide--trending-down] size-3"></span>
Declining
</span>
@elseif($buyer->engagement_trend === 'new')
<span class="badge badge-info">New</span>
@else
<span class="badge badge-ghost">Stable</span>
@endif
</td>
<td>{{ $buyer->last_interaction_at?->diffForHumans() ?? 'Never' }}</td>
<td>
<a href="{{ route('seller.business.analytics.buyers.show', [$business->slug, $buyer->buyer_business_id]) }}" class="btn btn-sm btn-ghost">
<span class="icon-[lucide--eye] size-4"></span>
</a>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center text-base-content/50">No buyer data available</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($buyers->hasPages())
<div class="mt-4">{{ $buyers->links() }}</div>
@endif
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
const scoreData = @json($scoreDistribution);
new ApexCharts(document.querySelector("#score-distribution-chart"), {
chart: { type: 'bar', height: 250, toolbar: { show: false } },
series: [{ name: 'Buyers', data: scoreData.map(d => d.count) }],
xaxis: { categories: scoreData.map(d => d.score_range) },
colors: ['#10b981']
}).render();
const trendData = @json($trendDistribution);
new ApexCharts(document.querySelector("#trend-distribution-chart"), {
chart: { type: 'donut', height: 250 },
series: trendData.map(d => d.count),
labels: trendData.map(d => ucwords(d.engagement_trend))
}).render();
function ucwords(str) {
return str.replace(/\b\w/g, l => l.toUpperCase());
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,24 @@
<x-seller-layout>
<div class="container mx-auto px-4 py-6">
<div class="mb-6">
<a href="{{ route('seller.business.analytics.marketing', $business->slug) }}" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Marketing Analytics
</a>
</div>
<div class="mb-6">
<h1 class="text-3xl font-bold">{{ $campaign->name }}</h1>
<p class="text-base-content/60">Campaign Analytics Detail</p>
</div>
<div class="grid gap-6">
<div class="card bg-base-100">
<div class="card-body">
<h2 class="card-title">Campaign Performance</h2>
<p>Detailed campaign analytics for {{ $campaign->name }} will be displayed here.</p>
</div>
</div>
</div>
</div>
</x-seller-layout>

View File

@@ -0,0 +1,328 @@
@extends('layouts.seller')
@section('title', 'Analytics Overview - ' . $business->name)
@section('content')
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Analytics Overview</h1>
<p class="text-base-content/70">Comprehensive analytics for {{ $business->name }}</p>
</div>
<!-- Period Selector -->
<div class="flex gap-2">
<select class="select select-bordered" onchange="window.location.href = '{{ route('seller.business.analytics.index', $business->slug) }}?period=' + this.value">
<option value="7" {{ $period == 7 ? 'selected' : '' }}>Last 7 days</option>
<option value="30" {{ $period == 30 ? 'selected' : '' }}>Last 30 days</option>
<option value="60" {{ $period == 60 ? 'selected' : '' }}>Last 60 days</option>
<option value="90" {{ $period == 90 ? 'selected' : '' }}>Last 90 days</option>
</select>
@if(hasBusinessPermission('analytics.export'))
<button class="btn btn-primary">
<span class="icon-[lucide--download] size-4"></span>
Export
</button>
@endif
</div>
</div>
<!-- Key Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<!-- Total Sessions -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm">Total Sessions</p>
<p class="text-3xl font-bold" data-counter="{{ $metrics['total_sessions'] }}">0</p>
</div>
<div class="p-3 bg-primary/10 rounded-lg">
<span class="icon-[lucide--users] size-8 text-primary"></span>
</div>
</div>
</div>
</div>
<!-- Page Views -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm">Total Page Views</p>
<p class="text-3xl font-bold" data-counter="{{ $metrics['total_page_views'] }}">0</p>
</div>
<div class="p-3 bg-secondary/10 rounded-lg">
<span class="icon-[lucide--eye] size-8 text-secondary"></span>
</div>
</div>
</div>
</div>
<!-- Product Views -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm">Product Views</p>
<p class="text-3xl font-bold" data-counter="{{ $metrics['total_product_views'] }}">0</p>
</div>
<div class="p-3 bg-accent/10 rounded-lg">
<span class="icon-[lucide--package] size-8 text-accent"></span>
</div>
</div>
</div>
</div>
<!-- Unique Products -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm">Unique Products Viewed</p>
<p class="text-3xl font-bold" data-counter="{{ $metrics['unique_products_viewed'] }}">0</p>
</div>
<div class="p-3 bg-info/10 rounded-lg">
<span class="icon-[lucide--layers] size-8 text-info"></span>
</div>
</div>
</div>
</div>
<!-- High Intent Signals -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm">High Intent Signals</p>
<p class="text-3xl font-bold" data-counter="{{ $metrics['high_intent_signals'] }}">0</p>
</div>
<div class="p-3 bg-warning/10 rounded-lg">
<span class="icon-[lucide--zap] size-8 text-warning"></span>
</div>
</div>
</div>
</div>
<!-- Active Buyers -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm">Active Buyers</p>
<p class="text-3xl font-bold" data-counter="{{ $metrics['active_buyers'] }}">0</p>
</div>
<div class="p-3 bg-success/10 rounded-lg">
<span class="icon-[lucide--trending-up] size-8 text-success"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Traffic Trend Chart -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Traffic Trend</h2>
<div id="traffic-trend-chart" style="height: 300px;"></div>
</div>
</div>
<!-- Engagement Distribution -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Buyer Engagement Distribution</h2>
<div id="engagement-distribution-chart" style="height: 300px;"></div>
</div>
</div>
</div>
<!-- Top Products and High-Value Buyers -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Top Products -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Top Products</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Product</th>
<th class="text-right">Views</th>
</tr>
</thead>
<tbody>
@forelse($topProducts as $productView)
<tr>
<td>
<div class="font-medium">{{ $productView->product?->name ?? 'Unknown Product' }}</div>
</td>
<td class="text-right">{{ number_format($productView->view_count) }}</td>
</tr>
@empty
<tr>
<td colspan="2" class="text-center text-base-content/50">No product views yet</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<!-- High-Value Buyers -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">High-Value Buyers</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Business</th>
<th class="text-right">Score</th>
</tr>
</thead>
<tbody>
@forelse($highValueBuyers as $buyer)
<tr>
<td>
<div class="font-medium">{{ $buyer->buyerBusiness?->name ?? 'Unknown Business' }}</div>
<div class="text-xs text-base-content/70">
Last active: {{ $buyer->last_interaction_at?->diffForHumans() ?? 'Never' }}
</div>
</td>
<td class="text-right">
<div class="badge badge-success">{{ $buyer->total_score }}</div>
</td>
</tr>
@empty
<tr>
<td colspan="2" class="text-center text-base-content/50">No buyer data yet</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Recent High-Intent Signals -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Recent High-Intent Signals (Last 24h)</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Buyer</th>
<th>Signal Type</th>
<th>Strength</th>
<th>Detected</th>
</tr>
</thead>
<tbody>
@forelse($recentIntentSignals as $signal)
<tr>
<td>
<div class="font-medium">{{ $signal->buyerBusiness?->name ?? 'Anonymous' }}</div>
@if($signal->user)
<div class="text-xs text-base-content/70">{{ $signal->user->name }}</div>
@endif
</td>
<td>
<span class="badge badge-outline">{{ ucwords(str_replace('_', ' ', $signal->signal_type)) }}</span>
</td>
<td>
@if($signal->signal_strength >= 75)
<span class="badge badge-error">Critical</span>
@elseif($signal->signal_strength >= 50)
<span class="badge badge-warning">High</span>
@else
<span class="badge badge-info">Medium</span>
@endif
</td>
<td class="text-sm text-base-content/70">{{ $signal->detected_at->diffForHumans() }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-base-content/50">No high-intent signals detected</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script src="https://cdn.jsdelivr.net/npm/animejs@3/lib/anime.min.js"></script>
<script>
// Animate counters
document.querySelectorAll('[data-counter]').forEach(el => {
const target = parseInt(el.dataset.counter);
anime({
targets: el,
innerHTML: [0, target],
easing: 'easeOutExpo',
round: 1,
duration: 2000
});
});
// Traffic Trend Chart
const trafficData = @json($trafficTrend);
const trafficOptions = {
chart: {
type: 'area',
height: 300,
toolbar: { show: false }
},
series: [
{
name: 'Events',
data: trafficData.map(d => d.total_events)
},
{
name: 'Sessions',
data: trafficData.map(d => d.unique_sessions)
}
],
xaxis: {
categories: trafficData.map(d => d.date)
},
stroke: {
curve: 'smooth'
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.7,
opacityTo: 0.3
}
}
};
new ApexCharts(document.querySelector("#traffic-trend-chart"), trafficOptions).render();
// Engagement Distribution Chart
const engagementData = @json($engagementDistribution);
const engagementOptions = {
chart: {
type: 'donut',
height: 300
},
series: engagementData.map(d => d.count),
labels: engagementData.map(d => d.score_range),
colors: ['#10b981', '#3b82f6', '#f59e0b', '#ef4444']
};
new ApexCharts(document.querySelector("#engagement-distribution-chart"), engagementOptions).render();
</script>
@endpush
@endsection

View File

@@ -0,0 +1,155 @@
@extends('layouts.seller')
@section('title', 'Marketing Analytics - ' . $business->name)
@section('content')
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Marketing Analytics</h1>
<p class="text-base-content/70">Email campaign performance and engagement</p>
</div>
<select class="select select-bordered" onchange="window.location.href = '{{ route('seller.business.analytics.marketing', $business->slug) }}?period=' + this.value">
<option value="7" {{ $period == 7 ? 'selected' : '' }}>Last 7 days</option>
<option value="30" {{ $period == 30 ? 'selected' : '' }}>Last 30 days</option>
<option value="60" {{ $period == 60 ? 'selected' : '' }}>Last 60 days</option>
<option value="90" {{ $period == 90 ? 'selected' : '' }}>Last 90 days</option>
</select>
</div>
<!-- Email Metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Campaigns</p>
<p class="text-3xl font-bold">{{ number_format($metrics['total_campaigns']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Total Sent</p>
<p class="text-3xl font-bold">{{ number_format($metrics['total_sent']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Open Rate</p>
<p class="text-3xl font-bold">{{ $metrics['avg_open_rate'] }}%</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Click Rate</p>
<p class="text-3xl font-bold">{{ $metrics['avg_click_rate'] }}%</p>
</div>
</div>
</div>
<!-- Engagement Trend -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="card-title">Email Engagement Over Time</h2>
<div id="engagement-trend-chart" style="height: 300px;"></div>
</div>
</div>
<!-- Device/Client Breakdown -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Email Clients</h2>
<div id="email-client-chart" style="height: 250px;"></div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Device Types</h2>
<div id="device-type-chart" style="height: 250px;"></div>
</div>
</div>
</div>
<!-- Campaign Performance -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Campaign Performance</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Campaign</th>
<th class="text-right">Sent</th>
<th class="text-right">Opened</th>
<th class="text-right">Clicked</th>
<th class="text-right">Open Rate</th>
<th class="text-right">Click Rate</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($campaigns as $campaign)
<tr>
<td>
<div class="font-medium">{{ $campaign->name }}</div>
<div class="text-xs text-base-content/70">{{ $campaign->sent_at?->format('M d, Y') }}</div>
</td>
<td class="text-right">{{ number_format($campaign->total_sent) }}</td>
<td class="text-right">{{ number_format($campaign->total_opened) }}</td>
<td class="text-right">{{ number_format($campaign->total_clicked) }}</td>
<td class="text-right">{{ $campaign->open_rate }}%</td>
<td class="text-right">{{ $campaign->click_rate }}%</td>
<td>
<a href="{{ route('seller.business.analytics.marketing.campaign', [$business->slug, $campaign->id]) }}" class="btn btn-sm btn-ghost">
<span class="icon-[lucide--eye] size-4"></span>
</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-center text-base-content/50">No campaigns yet</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($campaigns->hasPages())
<div class="mt-4">{{ $campaigns->links() }}</div>
@endif
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
const engagementData = @json($engagementTrend);
new ApexCharts(document.querySelector("#engagement-trend-chart"), {
chart: { type: 'area', height: 300, toolbar: { show: false } },
series: [
{ name: 'Sent', data: engagementData.map(d => d.sent) },
{ name: 'Opened', data: engagementData.map(d => d.opened) },
{ name: 'Clicked', data: engagementData.map(d => d.clicked) }
],
xaxis: { categories: engagementData.map(d => d.date) },
stroke: { curve: 'smooth' }
}).render();
const clientData = @json($emailClients);
new ApexCharts(document.querySelector("#email-client-chart"), {
chart: { type: 'pie', height: 250 },
series: clientData.map(d => d.count),
labels: clientData.map(d => d.email_client || 'Unknown')
}).render();
const deviceData = @json($deviceTypes);
new ApexCharts(document.querySelector("#device-type-chart"), {
chart: { type: 'donut', height: 250 },
series: deviceData.map(d => d.count),
labels: deviceData.map(d => d.device_type || 'Unknown')
}).render();
</script>
@endpush
@endsection

View File

@@ -0,0 +1,24 @@
<x-seller-layout>
<div class="container mx-auto px-4 py-6">
<div class="mb-6">
<a href="{{ route('seller.business.analytics.products', $business->slug) }}" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Products Analytics
</a>
</div>
<div class="mb-6">
<h1 class="text-3xl font-bold">{{ $product->name }}</h1>
<p class="text-base-content/60">Product Analytics Detail</p>
</div>
<div class="grid gap-6">
<div class="card bg-base-100">
<div class="card-body">
<h2 class="card-title">Product Performance</h2>
<p>Detailed analytics for {{ $product->name }} will be displayed here.</p>
</div>
</div>
</div>
</div>
</x-seller-layout>

View File

@@ -0,0 +1,131 @@
@extends('layouts.seller')
@section('title', 'Product Analytics - ' . $business->name)
@section('content')
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Product Analytics</h1>
<p class="text-base-content/70">Track product performance and engagement</p>
</div>
<select class="select select-bordered" onchange="window.location.href = '{{ route('seller.business.analytics.products', $business->slug) }}?period=' + this.value">
<option value="7" {{ $period == 7 ? 'selected' : '' }}>Last 7 days</option>
<option value="30" {{ $period == 30 ? 'selected' : '' }}>Last 30 days</option>
<option value="60" {{ $period == 60 ? 'selected' : '' }}>Last 60 days</option>
<option value="90" {{ $period == 90 ? 'selected' : '' }}>Last 90 days</option>
</select>
</div>
<!-- Engagement Breakdown -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Image Zooms</p>
<p class="text-2xl font-bold">{{ number_format($engagementBreakdown['zoomed_image']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Video Views</p>
<p class="text-2xl font-bold">{{ number_format($engagementBreakdown['watched_video']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Spec Downloads</p>
<p class="text-2xl font-bold">{{ number_format($engagementBreakdown['downloaded_spec']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Cart Adds</p>
<p class="text-2xl font-bold">{{ number_format($engagementBreakdown['added_to_cart']) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Wishlist Adds</p>
<p class="text-2xl font-bold">{{ number_format($engagementBreakdown['added_to_wishlist']) }}</p>
</div>
</div>
</div>
<!-- View Trend Chart -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="card-title">Product Views Over Time</h2>
<div id="view-trend-chart" style="height: 300px;"></div>
</div>
</div>
<!-- Product Performance Table -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Product Performance</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Product</th>
<th class="text-right">Total Views</th>
<th class="text-right">Unique Buyers</th>
<th class="text-right">Avg Time</th>
<th class="text-right">Cart Adds</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($productMetrics as $metric)
<tr>
<td>
<div class="font-medium">{{ $metric->product?->name ?? 'Unknown' }}</div>
<div class="text-xs text-base-content/70">{{ $metric->product?->brand?->name }}</div>
</td>
<td class="text-right">{{ number_format($metric->total_views) }}</td>
<td class="text-right">{{ number_format($metric->unique_buyers) }}</td>
<td class="text-right">{{ number_format($metric->avg_time_on_page ?? 0) }}s</td>
<td class="text-right">{{ number_format($metric->cart_additions) }}</td>
<td>
<a href="{{ route('seller.business.analytics.products.show', [$business->slug, $metric->product_id]) }}" class="btn btn-sm btn-ghost">
<span class="icon-[lucide--eye] size-4"></span>
</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-base-content/50">No product data available</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($productMetrics->hasPages())
<div class="mt-4">
{{ $productMetrics->links() }}
</div>
@endif
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
const viewTrend = @json($viewTrend);
const options = {
chart: { type: 'line', height: 300, toolbar: { show: false } },
series: [
{ name: 'Views', data: viewTrend.map(d => d.views) },
{ name: 'Unique Buyers', data: viewTrend.map(d => d.unique_buyers) }
],
xaxis: { categories: viewTrend.map(d => d.date) },
stroke: { curve: 'smooth' }
};
new ApexCharts(document.querySelector("#view-trend-chart"), options).render();
</script>
@endpush
@endsection

View File

@@ -0,0 +1,209 @@
@extends('layouts.seller')
@section('title', 'Sales Analytics - ' . $business->name)
@section('content')
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Sales Analytics</h1>
<p class="text-base-content/70">Sales funnel and conversion metrics</p>
</div>
<select class="select select-bordered" onchange="window.location.href = '{{ route('seller.business.analytics.sales', $business->slug) }}?period=' + this.value">
<option value="7" {{ $period == 7 ? 'selected' : '' }}>Last 7 days</option>
<option value="30" {{ $period == 30 ? 'selected' : '' }}>Last 30 days</option>
<option value="60" {{ $period == 60 ? 'selected' : '' }}>Last 60 days</option>
<option value="90" {{ $period == 90 ? 'selected' : '' }}>Last 90 days</option>
</select>
</div>
<!-- Sales Metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Total Orders</p>
<p class="text-3xl font-bold">{{ number_format($salesMetrics->total_orders ?? 0) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Total Revenue</p>
<p class="text-3xl font-bold">${{ number_format($salesMetrics->total_revenue ?? 0, 2) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Avg Order Value</p>
<p class="text-3xl font-bold">${{ number_format($salesMetrics->avg_order_value ?? 0, 2) }}</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="text-base-content/70 text-sm">Unique Buyers</p>
<p class="text-3xl font-bold">{{ number_format($salesMetrics->unique_buyers ?? 0) }}</p>
</div>
</div>
</div>
<!-- Conversion Funnel -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Conversion Funnel</h2>
<div class="space-y-4">
<div>
<div class="flex justify-between mb-2">
<span>Total Sessions</span>
<span class="font-bold">{{ number_format($funnelMetrics['total_sessions']) }}</span>
</div>
<div class="w-full bg-base-300 rounded-full h-8">
<div class="bg-primary h-8 rounded-full flex items-center justify-end px-4 text-primary-content" style="width: 100%">
100%
</div>
</div>
</div>
<div>
<div class="flex justify-between mb-2">
<span>With Product Views</span>
<span class="font-bold">{{ number_format($funnelMetrics['sessions_with_product_views']) }}</span>
</div>
<div class="w-full bg-base-300 rounded-full h-8">
<div class="bg-secondary h-8 rounded-full flex items-center justify-end px-4 text-secondary-content" style="width: {{ $funnelMetrics['product_view_rate'] }}%">
{{ $funnelMetrics['product_view_rate'] }}%
</div>
</div>
</div>
<div>
<div class="flex justify-between mb-2">
<span>Added to Cart</span>
<span class="font-bold">{{ number_format($funnelMetrics['sessions_with_cart']) }}</span>
</div>
<div class="w-full bg-base-300 rounded-full h-8">
<div class="bg-accent h-8 rounded-full flex items-center justify-end px-4 text-accent-content" style="width: {{ $funnelMetrics['cart_rate'] }}%">
{{ $funnelMetrics['cart_rate'] }}%
</div>
</div>
</div>
<div>
<div class="flex justify-between mb-2">
<span>Checkout Initiated</span>
<span class="font-bold">{{ number_format($funnelMetrics['checkout_initiated']) }}</span>
</div>
<div class="w-full bg-base-300 rounded-full h-8">
<div class="bg-warning h-8 rounded-full flex items-center justify-end px-4 text-warning-content" style="width: {{ $funnelMetrics['checkout_rate'] }}%">
{{ $funnelMetrics['checkout_rate'] }}%
</div>
</div>
</div>
<div>
<div class="flex justify-between mb-2">
<span>Orders Completed</span>
<span class="font-bold">{{ number_format($funnelMetrics['orders_completed']) }}</span>
</div>
<div class="w-full bg-base-300 rounded-full h-8">
<div class="bg-success h-8 rounded-full flex items-center justify-end px-4 text-success-content" style="width: {{ $funnelMetrics['conversion_rate'] }}%">
{{ $funnelMetrics['conversion_rate'] }}%
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Revenue Trend -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="card-title">Revenue Trend</h2>
<div id="revenue-trend-chart" style="height: 300px;"></div>
</div>
</div>
<!-- Top Products and Buyers -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Top Revenue Products</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Product</th>
<th class="text-right">Units</th>
<th class="text-right">Revenue</th>
</tr>
</thead>
<tbody>
@forelse($topProducts as $product)
<tr>
<td class="font-medium">{{ $product->name }}</td>
<td class="text-right">{{ number_format($product->units_sold) }}</td>
<td class="text-right">${{ number_format($product->revenue, 2) }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center text-base-content/50">No sales data</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title mb-4">Top Buyers</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Business</th>
<th class="text-right">Orders</th>
<th class="text-right">Revenue</th>
</tr>
</thead>
<tbody>
@forelse($topBuyers as $buyer)
<tr>
<td class="font-medium">{{ $buyer->name }}</td>
<td class="text-right">{{ number_format($buyer->order_count) }}</td>
<td class="text-right">${{ number_format($buyer->total_revenue, 2) }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center text-base-content/50">No buyer data</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
const revenueData = @json($revenueTrend);
new ApexCharts(document.querySelector("#revenue-trend-chart"), {
chart: { type: 'area', height: 300, toolbar: { show: false } },
series: [
{ name: 'Revenue', data: revenueData.map(d => d.revenue) },
{ name: 'Orders', data: revenueData.map(d => d.orders) }
],
xaxis: { categories: revenueData.map(d => d.date) },
stroke: { curve: 'smooth' },
yaxis: [
{ title: { text: 'Revenue ($)' } },
{ opposite: true, title: { text: 'Orders' } }
]
}).render();
</script>
@endpush
@endsection

View File

@@ -0,0 +1,412 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Add New Brand</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.brands', $business->slug) }}">Brands</a></li>
<li class="opacity-80">Add New</li>
</ul>
</div>
</div>
<form action="{{ route('seller.business.brands.store', $business->slug) }}" method="POST" enctype="multipart/form-data" class="mt-6">
@csrf
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Brand Information</h2>
<!-- Brand Name -->
<div class="form-control">
<label class="label">
<span class="label-text">Brand Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name') }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="Enter brand name" required>
@error('name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Tagline -->
<div class="form-control">
<label class="label">
<span class="label-text">Tagline</span>
</label>
<input type="text" name="tagline" value="{{ old('tagline') }}"
class="input input-bordered @error('tagline') input-error @enderror"
placeholder="Enter brand tagline">
@error('tagline')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Brand Website -->
<div class="form-control">
<label class="label">
<span class="label-text">Brand Website</span>
</label>
<div class="join w-full">
<select name="website_protocol" class="select select-bordered join-item">
<option value="https://" {{ old('website_protocol', 'https://') == 'https://' ? 'selected' : '' }}>https://</option>
<option value="http://" {{ old('website_protocol') == 'http://' ? 'selected' : '' }}>http://</option>
</select>
<input type="text" name="website_domain" value="{{ old('website_domain') }}"
class="input input-bordered join-item flex-1 @error('website_url') input-error @enderror"
placeholder="www.example.com">
</div>
@error('website_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Short Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Short Description</span>
<span class="label-text-alt" id="description-count">0/300</span>
</label>
<textarea name="description"
class="textarea textarea-bordered h-24 @error('description') textarea-error @enderror"
placeholder="Brief description of the brand"
maxlength="300"
oninput="updateCharCount('description', 'description-count', 300)">{{ old('description') }}</textarea>
@error('description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Long Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Long Description</span>
<span class="label-text-alt" id="long-description-count">0/700</span>
</label>
<textarea name="long_description"
class="textarea textarea-bordered h-32 @error('long_description') textarea-error @enderror"
placeholder="Detailed description of the brand"
maxlength="700"
oninput="updateCharCount('long_description', 'long-description-count', 700)">{{ old('long_description') }}</textarea>
@error('long_description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<div class="divider"></div>
<h3 class="font-semibold text-lg">Address Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Address -->
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Address</span>
</label>
<input type="text" name="address" value="{{ old('address') }}"
class="input input-bordered @error('address') input-error @enderror"
placeholder="Street address">
@error('address')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Unit Number -->
<div class="form-control">
<label class="label">
<span class="label-text">Unit Number</span>
</label>
<input type="text" name="unit_number" value="{{ old('unit_number') }}"
class="input input-bordered @error('unit_number') input-error @enderror"
placeholder="Unit, Suite, etc.">
@error('unit_number')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Zip Code -->
<div class="form-control">
<label class="label">
<span class="label-text">Zip Code</span>
</label>
<input type="text" name="zip_code" value="{{ old('zip_code') }}"
class="input input-bordered @error('zip_code') input-error @enderror"
placeholder="12345">
@error('zip_code')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- City -->
<div class="form-control">
<label class="label">
<span class="label-text">City</span>
</label>
<input type="text" name="city" value="{{ old('city') }}"
class="input input-bordered @error('city') input-error @enderror"
placeholder="City">
@error('city')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- State -->
<div class="form-control">
<label class="label">
<span class="label-text">State</span>
</label>
<input type="text" name="state" value="{{ old('state') }}"
class="input input-bordered @error('state') input-error @enderror"
placeholder="AZ" maxlength="2">
@error('state')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<!-- Brand Phone -->
<div class="form-control">
<label class="label">
<span class="label-text">Brand Phone</span>
</label>
<input type="tel" name="phone" value="{{ old('phone') }}"
class="input input-bordered @error('phone') input-error @enderror"
placeholder="(555) 123-4567">
@error('phone')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<div class="divider"></div>
<h3 class="font-semibold text-lg">Brand Images</h3>
<!-- Brand Image -->
<div class="form-control">
<label class="label">
<span class="label-text">Brand Image (Logo)</span>
</label>
<input type="file" name="logo" accept="image/*"
class="file-input file-input-bordered @error('logo') file-input-error @enderror"
onchange="previewImage(this, 'logo-preview')">
<label class="label">
<span class="label-text-alt">Maximum file size: 2MB. Recommended: 500x500px</span>
</label>
@error('logo')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="logo-preview" class="mt-2 hidden">
<img src="" alt="Logo preview" class="max-w-xs rounded-lg shadow-md">
</div>
</div>
<!-- Brand Banner -->
<div class="form-control">
<label class="label">
<span class="label-text">Brand Banner</span>
</label>
<input type="file" name="banner" accept="image/*"
class="file-input file-input-bordered @error('banner') file-input-error @enderror"
onchange="previewImage(this, 'banner-preview')">
<label class="label">
<span class="label-text-alt">Maximum file size: 4MB. Recommended: 1920x400px</span>
</label>
@error('banner')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="banner-preview" class="mt-2 hidden">
<img src="" alt="Banner preview" class="max-w-full rounded-lg shadow-md">
</div>
</div>
<div class="divider"></div>
<h3 class="font-semibold text-lg">Social Media</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Facebook -->
<div class="form-control">
<label class="label">
<span class="label-text">Facebook</span>
</label>
<input type="url" name="facebook_url" value="{{ old('facebook_url') }}"
class="input input-bordered @error('facebook_url') input-error @enderror"
placeholder="https://facebook.com/yourbrand">
@error('facebook_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Instagram -->
<div class="form-control">
<label class="label">
<span class="label-text">Instagram</span>
</label>
<div class="join w-full">
<span class="join-item flex items-center bg-base-200 px-3">@</span>
<input type="text" name="instagram_handle" value="{{ old('instagram_handle') }}"
class="input input-bordered join-item flex-1 @error('instagram_handle') input-error @enderror"
placeholder="yourbrand">
</div>
@error('instagram_handle')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Twitter -->
<div class="form-control">
<label class="label">
<span class="label-text">Twitter</span>
</label>
<div class="join w-full">
<span class="join-item flex items-center bg-base-200 px-3">@</span>
<input type="text" name="twitter_handle" value="{{ old('twitter_handle') }}"
class="input input-bordered join-item flex-1 @error('twitter_handle') input-error @enderror"
placeholder="yourbrand">
</div>
@error('twitter_handle')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- YouTube -->
<div class="form-control">
<label class="label">
<span class="label-text">YouTube</span>
</label>
<input type="url" name="youtube_url" value="{{ old('youtube_url') }}"
class="input input-bordered @error('youtube_url') input-error @enderror"
placeholder="https://youtube.com/@yourbrand">
@error('youtube_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<div class="divider"></div>
<h3 class="font-semibold text-lg">Visibility</h3>
<!-- Public Menu Checkbox -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="is_public" value="1"
{{ old('is_public') ? 'checked' : '' }}
class="checkbox checkbox-primary">
<div>
<span class="label-text font-medium">Appears on Public Menu</span>
<p class="text-sm text-base-content/60">Make this brand visible to buyers in the marketplace</p>
</div>
</label>
</div>
<!-- Active Status -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="is_active" value="1"
{{ old('is_active', true) ? 'checked' : '' }}
class="checkbox checkbox-primary">
<div>
<span class="label-text font-medium">Active</span>
<p class="text-sm text-base-content/60">Brand is active and can be used for products</p>
</div>
</label>
</div>
<!-- Featured -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="is_featured" value="1"
{{ old('is_featured') ? 'checked' : '' }}
class="checkbox checkbox-primary">
<div>
<span class="label-text font-medium">Featured Brand</span>
<p class="text-sm text-base-content/60">Highlight this brand on the marketplace</p>
</div>
</label>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-6 flex gap-3 justify-end">
<a href="{{ route('seller.business.settings.brands', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary">
Create Brand
</button>
</div>
</form>
@push('scripts')
<script>
// Character count for textareas
function updateCharCount(fieldName, counterId, maxLength) {
const field = document.querySelector(`[name="${fieldName}"]`);
const counter = document.getElementById(counterId);
const currentLength = field.value.length;
counter.textContent = `${currentLength}/${maxLength}`;
}
// Initialize character counts on page load
document.addEventListener('DOMContentLoaded', function() {
updateCharCount('description', 'description-count', 300);
updateCharCount('long_description', 'long-description-count', 700);
});
// Image preview functionality
function previewImage(input, previewId) {
const preview = document.getElementById(previewId);
const img = preview.querySelector('img');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
img.src = e.target.result;
preview.classList.remove('hidden');
}
reader.readAsDataURL(input.files[0]);
} else {
preview.classList.add('hidden');
}
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,520 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Editing {{ $brand->name }}</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.brands', $business->slug) }}">Brands</a></li>
<li class="opacity-80">{{ $brand->name }}</li>
</ul>
</div>
</div>
<form action="{{ route('seller.business.brands.update', [$business->slug, $brand]) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Brand Information Section -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Brand Information</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Brand Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Brand Name <span class="text-error">*</span></span>
<span class="label-text-alt tooltip tooltip-left" data-tip="The official name of your brand">
<span class="iconify lucide--info size-4"></span>
</span>
</label>
<input type="text" name="name" value="{{ old('name', $brand->name) }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="Enter brand name" required>
@error('name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Tagline -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Tagline</span>
<span class="label-text-alt text-sm" id="tagline-count">0/45</span>
</label>
<input type="text" name="tagline" value="{{ old('tagline', $brand->tagline) }}"
class="input input-bordered @error('tagline') input-error @enderror"
placeholder="A short, catchy phrase that represents your brand"
maxlength="45"
oninput="updateCharCount('tagline', 'tagline-count', 45)">
@error('tagline')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
<!-- Brand Website -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Brand Website</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="Your brand's website URL (https:// added automatically)">
<span class="iconify lucide--info size-4"></span>
</span>
</label>
@php
$websiteUrl = old('website_url', $brand->website_url);
$websiteDomain = '';
if ($websiteUrl) {
if (str_starts_with($websiteUrl, 'http://')) {
$websiteDomain = substr($websiteUrl, 7);
} elseif (str_starts_with($websiteUrl, 'https://')) {
$websiteDomain = substr($websiteUrl, 8);
} else {
$websiteDomain = $websiteUrl;
}
}
@endphp
<input type="text" name="website_url" value="{{ old('website_url', $websiteDomain) }}"
class="input input-bordered @error('website_url') input-error @enderror"
placeholder="example.com">
@error('website_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Brand Phone -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Brand Phone</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="Contact phone number for this brand">
<span class="iconify lucide--info size-4"></span>
</span>
</label>
<input type="tel" name="phone" value="{{ old('phone', $brand->phone) }}"
class="input input-bordered @error('phone') input-error @enderror"
placeholder="(555) 123-4567">
@error('phone')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<!-- Short Description -->
<div class="form-control mt-4 w-full">
<label class="label">
<span class="label-text font-medium">Short Description</span>
<span class="label-text-alt">
<span class="tooltip tooltip-left mr-2" data-tip="Brief description shown in brand listings">
<span class="iconify lucide--info size-4"></span>
</span>
<span class="text-sm" id="description-count">0/300</span>
</span>
</label>
<textarea name="description"
rows="3"
class="textarea textarea-bordered resize-none w-full @error('description') textarea-error @enderror"
placeholder="Brief description of the brand"
maxlength="300"
oninput="updateCharCount('description', 'description-count', 300)">{{ old('description', $brand->description) }}</textarea>
@error('description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Long Description -->
<div class="form-control mt-4 w-full">
<label class="label">
<span class="label-text font-medium">Long Description</span>
<span class="label-text-alt">
<span class="tooltip tooltip-left mr-2" data-tip="Detailed brand story shown on brand preview page">
<span class="iconify lucide--info size-4"></span>
</span>
<span class="text-sm" id="long-description-count">0/1000</span>
</span>
</label>
<textarea name="long_description"
rows="10"
class="textarea textarea-bordered resize-none w-full @error('long_description') textarea-error @enderror"
placeholder="Tell buyers about your brand's story, values, and what makes your products unique"
maxlength="1000"
oninput="updateCharCount('long_description', 'long-description-count', 1000)">{{ old('long_description', $brand->long_description) }}</textarea>
@error('long_description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Address Information Section -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Address Information</h2>
<!-- Street Address -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<input type="text" name="address" value="{{ old('address', $brand->address) }}"
class="input input-bordered @error('address') input-error @enderror"
placeholder="123 Main Street">
@error('address')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6 mt-4">
<!-- Unit Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Unit/Suite</span>
</label>
<input type="text" name="unit_number" value="{{ old('unit_number', $brand->unit_number) }}"
class="input input-bordered @error('unit_number') input-error @enderror"
placeholder="Suite 100">
@error('unit_number')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- City -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input type="text" name="city" value="{{ old('city', $brand->city) }}"
class="input input-bordered @error('city') input-error @enderror"
placeholder="Phoenix">
@error('city')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- State -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">State</span>
</label>
<input type="text" name="state" value="{{ old('state', $brand->state) }}"
class="input input-bordered @error('state') input-error @enderror"
placeholder="AZ" maxlength="2">
@error('state')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Zip Code -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Zip Code</span>
</label>
<input type="text" name="zip_code" value="{{ old('zip_code', $brand->zip_code) }}"
class="input input-bordered @error('zip_code') input-error @enderror"
placeholder="85001">
@error('zip_code')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Brand Images Section -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Brand Images</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Brand Logo -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Brand Logo</span>
</label>
@if($brand->hasLogo())
<div class="mb-3">
<img src="{{ $brand->getLogoUrl() }}" alt="{{ $brand->name }} logo" class="w-32 h-32 rounded-lg shadow object-contain border border-base-300">
</div>
@endif
<input type="file" name="logo" accept="image/*"
class="file-input file-input-bordered @error('logo') file-input-error @enderror"
onchange="previewImage(this, 'logo-preview')">
<label class="label">
<span class="label-text-alt text-base-content/60">Max: 2MB | Recommended: 500x500px</span>
</label>
@error('logo')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="logo-preview" class="mt-2 hidden">
<p class="text-sm text-base-content/60 mb-1">Preview:</p>
<img src="" alt="Logo preview" class="w-32 h-32 rounded-lg shadow object-contain border border-base-300">
</div>
@if($brand->hasLogo())
<div class="form-control mt-3">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="remove_logo" value="1" class="checkbox checkbox-sm">
<span class="label-text text-sm">Remove current logo</span>
</label>
</div>
@endif
</div>
<!-- Brand Banner -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Brand Banner</span>
</label>
@if($brand->banner_path && \Storage::disk('public')->exists($brand->banner_path))
<div class="mb-3">
<img src="{{ asset('storage/' . $brand->banner_path) }}" alt="{{ $brand->name }} banner" class="w-full h-32 rounded-lg shadow object-cover border border-base-300">
</div>
@endif
<input type="file" name="banner" accept="image/*"
class="file-input file-input-bordered @error('banner') file-input-error @enderror"
onchange="previewImage(this, 'banner-preview')">
<label class="label">
<span class="label-text-alt text-base-content/60">Max: 4MB | Recommended: 1920x400px</span>
</label>
@error('banner')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="banner-preview" class="mt-2 hidden">
<p class="text-sm text-base-content/60 mb-1">Preview:</p>
<img src="" alt="Banner preview" class="w-full h-32 rounded-lg shadow object-cover border border-base-300">
</div>
@if($brand->banner_path && \Storage::disk('public')->exists($brand->banner_path))
<div class="form-control mt-3">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="remove_banner" value="1" class="checkbox checkbox-sm">
<span class="label-text text-sm">Remove current banner</span>
</label>
</div>
@endif
</div>
</div>
</div>
</div>
<!-- Social Media Section -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Social Media</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Instagram -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium inline-flex items-center gap-2">
<span class="iconify lucide--instagram size-4 text-base-content/50"></span>
Instagram
</span>
</label>
<div class="join w-full">
<span class="join-item flex items-center bg-base-200 px-3 text-sm">@</span>
<input type="text" name="instagram_handle" value="{{ old('instagram_handle', $brand->instagram_handle) }}"
class="input input-bordered join-item flex-1 @error('instagram_handle') input-error @enderror"
placeholder="yourbrand">
</div>
@error('instagram_handle')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Twitter -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium inline-flex items-center gap-2">
<span class="iconify lucide--twitter size-4 text-base-content/50"></span>
Twitter
</span>
</label>
<div class="join w-full">
<span class="join-item flex items-center bg-base-200 px-3 text-sm">@</span>
<input type="text" name="twitter_handle" value="{{ old('twitter_handle', $brand->twitter_handle) }}"
class="input input-bordered join-item flex-1 @error('twitter_handle') input-error @enderror"
placeholder="yourbrand">
</div>
@error('twitter_handle')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Facebook -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium inline-flex items-center gap-2">
<span class="iconify lucide--facebook size-4 text-base-content/50"></span>
Facebook
</span>
</label>
<input type="url" name="facebook_url" value="{{ old('facebook_url', $brand->facebook_url) }}"
class="input input-bordered @error('facebook_url') input-error @enderror"
placeholder="https://facebook.com/yourbrand">
@error('facebook_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- YouTube -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium inline-flex items-center gap-2">
<span class="iconify lucide--youtube size-4 text-base-content/50"></span>
YouTube
</span>
</label>
<input type="url" name="youtube_url" value="{{ old('youtube_url', $brand->youtube_url) }}"
class="input input-bordered @error('youtube_url') input-error @enderror"
placeholder="https://youtube.com/@yourbrand">
@error('youtube_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Visibility Settings Section -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Visibility Settings</h2>
<div class="space-y-4">
<!-- Public Menu Checkbox -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="is_public" value="1"
{{ old('is_public', $brand->is_public) ? 'checked' : '' }}
class="checkbox">
<div>
<span class="label-text font-medium">Public Menu</span>
<p class="text-sm text-base-content/60">Make this brand visible to buyers in the marketplace</p>
</div>
</label>
</div>
<!-- Active Status -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="is_active" value="1"
{{ old('is_active', $brand->is_active) ? 'checked' : '' }}
class="checkbox">
<div>
<span class="label-text font-medium">Active</span>
<p class="text-sm text-base-content/60">Brand is active and can be used for products</p>
</div>
</label>
</div>
<!-- Featured -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="is_featured" value="1"
{{ old('is_featured', $brand->is_featured) ? 'checked' : '' }}
class="checkbox">
<div>
<span class="label-text font-medium">Featured Brand</span>
<p class="text-sm text-base-content/60">Highlight this brand on the marketplace</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 justify-end mb-6">
<a href="{{ route('seller.business.settings.brands', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn btn-primary">
<span class="iconify lucide--save size-4 mr-2"></span>
Update Brand
</button>
</div>
</form>
@push('scripts')
<script>
// Character count for textareas
function updateCharCount(fieldName, counterId, maxLength) {
const field = document.querySelector(`[name="${fieldName}"]`);
const counter = document.getElementById(counterId);
if (field && counter) {
const currentLength = field.value.length;
counter.textContent = `${currentLength}/${maxLength}`;
}
}
// Initialize character counts on page load
document.addEventListener('DOMContentLoaded', function() {
updateCharCount('tagline', 'tagline-count', 45);
updateCharCount('description', 'description-count', 300);
updateCharCount('long_description', 'long-description-count', 1000);
});
// Image preview functionality
function previewImage(input, previewId) {
const preview = document.getElementById(previewId);
const img = preview.querySelector('img');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
img.src = e.target.result;
preview.classList.remove('hidden');
}
reader.readAsDataURL(input.files[0]);
} else {
preview.classList.add('hidden');
}
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,545 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumbs -->
<div class="breadcrumbs text-sm mb-6">
<ul>
@if($isSeller)
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.brands', $business->slug) }}">Brands</a></li>
<li class="opacity-60">{{ $brand->name }}</li>
@else
<li><a href="{{ route('buyer.dashboard') }}">Dashboard</a></li>
<li><a href="{{ route('buyer.browse') }}">Browse</a></li>
<li class="opacity-60">{{ $brand->name }}</li>
@endif
</ul>
</div>
@if($isSeller)
<!-- Context Banner (Seller Only) -->
<div class="alert bg-info/10 border-info/20 mb-6">
<span class="iconify lucide--eye size-5 text-info"></span>
<span class="text-sm">Below is a preview of how your menu appears to retailers when they shop <span class="font-semibold">{{ $brand->name }}</span>.</span>
</div>
<!-- Preview Menu Header (Seller Only) -->
<div class="mb-6">
<h1 class="text-2xl font-bold mb-1">Preview Menu</h1>
<p class="text-base-content/70">Edit product order and preview your menu</p>
</div>
<!-- General Information and Menu Checklist (Seller Only) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- General Information -->
<div class="bg-base-100 border border-base-300 p-6">
<h2 class="text-lg font-semibold mb-4">General Information</h2>
<div class="space-y-4 text-sm text-base-content/80">
<p>Your menu is what retailers see when shopping {{ $brand->name }}—let's make it shine.</p>
<p>Areas highlighted in red show recommended updates to help your products stand out and attract more retailer interest. These tips come directly from feedback we've received from buyers and are designed to make your listings even more effective. Don't worry—these notes are <span class="text-error font-medium">only visible to you</span> and never shown to retailers.</p>
<p>Looking for personalized suggestions or extra support? Our Client Experience Team is always happy to help you get the most out of your menu. Reach out anytime at <a href="mailto:support@cannabrands.com" class="link link-primary">support@cannabrands.com</a>.</p>
</div>
</div>
<!-- Menu Checklist -->
<div class="bg-base-100 border border-base-300 p-6">
<h2 class="text-lg font-semibold mb-4">Menu Checklist</h2>
<p class="text-sm text-base-content/80 mb-4">Let's make sure your menu is ready to impress!</p>
<ul class="space-y-3 text-sm text-base-content/80">
<li class="flex items-start gap-2">
<span class="iconify lucide--check-circle size-5 text-success mt-0.5 flex-shrink-0"></span>
<span>Are all your products showing up the way you expect?</span>
</li>
<li class="flex items-start gap-2">
<span class="iconify lucide--check-circle size-5 text-success mt-0.5 flex-shrink-0"></span>
<span>Do each of your products have great images and clear descriptions?</span>
</li>
<li class="flex items-start gap-2">
<span class="iconify lucide--check-circle size-5 text-success mt-0.5 flex-shrink-0"></span>
<span>Are your items organized by product line? (That's especially helpful for bigger menus!)</span>
</li>
<li class="flex items-start gap-2">
<span class="iconify lucide--check-circle size-5 text-success mt-0.5 flex-shrink-0"></span>
<span>Have you double-checked your sample requests and min/max order quantities?</span>
</li>
</ul>
</div>
</div>
<!-- Share Menu Button (Seller Only) -->
<div class="flex justify-end mb-6">
<button class="btn btn-primary gap-2" onclick="share_menu_modal.showModal()">
<span class="iconify lucide--share-2 size-4"></span>
Share Menu
</button>
</div>
@endif
<!-- Hero Banner Section -->
<div class="relative w-full mb-8 rounded overflow-hidden" style="height: 300px;">
@if($brand->banner_path)
<!-- Brand Banner Image -->
<img src="{{ asset('storage/' . $brand->banner_path) }}"
alt="{{ $brand->name }} banner"
class="w-full h-full object-cover">
<!-- Overlay with brand logo and name -->
<div class="absolute inset-0 flex items-center justify-center bg-black/20">
<div class="text-center">
@if($brand->hasLogo())
<div class="mb-4">
<img src="{{ $brand->getLogoUrl() }}"
alt="{{ $brand->name }}"
class="max-h-24 mx-auto drop-shadow-2xl">
</div>
@endif
<h1 class="text-5xl font-bold text-white drop-shadow-2xl" style="text-shadow: 2px 2px 8px rgba(0,0,0,0.8);">
{{ $brand->name }}
</h1>
</div>
</div>
@else
<!-- Fallback: No banner image -->
<div class="w-full h-full bg-gradient-to-r from-primary/20 to-secondary/20 flex items-center justify-center">
<div class="text-center">
@if($brand->hasLogo())
<div class="mb-4">
<img src="{{ $brand->getLogoUrl() }}"
alt="{{ $brand->name }}"
class="max-h-24 mx-auto">
</div>
@endif
<h1 class="text-5xl font-bold text-base-content">
{{ $brand->name }}
</h1>
</div>
</div>
@endif
</div>
<!-- Brand Information Section -->
<div class="bg-base-100 border border-base-300 mb-8">
<div class="p-8">
<div class="flex flex-col lg:flex-row gap-8">
<!-- Brand Details -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
@if($brand->tagline)
<p class="text-xl text-base-content/70 mb-2">{{ $brand->tagline }}</p>
@endif
</div>
<span class="px-2 py-1 text-xs font-medium border border-base-300 bg-base-50">
{{ strtoupper($brand->business->license_type ?? 'MED') }}
</span>
</div>
@if($brand->description)
<p class="text-sm text-base-content/80 mb-4 leading-relaxed">{{ $brand->description }}</p>
@endif
<div class="flex flex-wrap gap-3 mb-4">
@if($brand->website_url)
<a href="{{ $brand->website_url }}" target="_blank"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-base-200/50 hover:bg-base-300/50 border border-base-300 rounded-lg transition-colors group">
<span class="iconify lucide--globe size-4 text-base-content/50 group-hover:text-base-content/70"></span>
<span class="text-base-content/70 group-hover:text-base-content">{{ parse_url($brand->website_url, PHP_URL_HOST) }}</span>
</a>
@endif
@if($brand->instagram_handle)
<a href="https://instagram.com/{{ $brand->instagram_handle }}" target="_blank"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-base-200/50 hover:bg-base-300/50 border border-base-300 rounded-lg transition-colors group">
<span class="iconify lucide--instagram size-4 text-base-content/50 group-hover:text-base-content/70"></span>
<span class="text-base-content/70 group-hover:text-base-content">@{{ $brand->instagram_handle }}</span>
</a>
@endif
</div>
<!-- About the Company Button -->
<button class="btn btn-sm btn-outline gap-2" onclick="about_company_modal.showModal()">
<span class="iconify lucide--building-2 size-4"></span>
About the Company
</button>
</div>
<!-- Seller Sidebar -->
<div class="lg:w-64 flex-shrink-0 border-t lg:border-t-0 lg:border-l border-base-300 pt-6 lg:pt-0 lg:pl-8">
<div class="text-sm mb-4">
<p class="text-base-content/60 mb-1">Distributed by</p>
<p class="font-medium">{{ $brand->business->name }}</p>
</div>
@if($otherBrands->count() > 0)
<div class="mt-6">
<label class="block text-sm text-base-content/60 mb-2">Other brands from this seller</label>
<select class="select select-bordered select-sm w-full"
onchange="if(this.value) window.location.href=this.value">
<option value="">Select a brand</option>
@foreach($otherBrands as $otherBrand)
@if($isSeller)
<option value="{{ route('seller.business.brands.preview', [$business->slug, $otherBrand->slug]) }}">
{{ $otherBrand->name }}
</option>
@else
<option value="{{ route('buyer.brands.browse', [$business->slug, $otherBrand->slug]) }}">
{{ $otherBrand->name }}
</option>
@endif
@endforeach
</select>
</div>
@endif
</div>
</div>
</div>
</div>
<!-- Products Section -->
@if($productsByLine->count() > 0)
@foreach($productsByLine as $lineName => $lineProducts)
<div class="bg-base-100 border border-base-300 mb-6">
<div class="border-b border-base-300 px-6 py-4">
<h2 class="text-lg font-semibold">{{ $lineName }}</h2>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="border-base-300">
<th class="bg-base-50">Product</th>
<th class="bg-base-50">Type</th>
<th class="bg-base-50 text-right">Price</th>
<th class="bg-base-50 text-center">Availability</th>
<th class="bg-base-50 text-center">QTY</th>
@if(!$isSeller)
<th class="bg-base-50"></th>
@endif
</tr>
</thead>
<tbody>
@foreach($lineProducts as $product)
<tr class="border-base-300 hover:bg-base-50">
<td class="py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 flex-shrink-0 border border-base-300 bg-base-50">
@if($product->images && $product->images->first())
<img src="{{ $product->images->first()->getUrl() }}"
alt="{{ $product->name }}"
class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center">
<span class="iconify lucide--package size-5 text-base-content/20"></span>
</div>
@endif
</div>
<div class="min-w-0">
<div class="font-medium text-sm">{{ $product->name }}</div>
<div class="text-xs text-base-content/50">{{ $product->sku }}</div>
</div>
</div>
</td>
<td>
@if($product->strain)
<span class="text-xs px-2 py-1 border border-base-300 bg-base-50">
{{ ucfirst($product->strain->classification ?? 'N/A') }}
</span>
@else
<span class="text-base-content/30"></span>
@endif
</td>
<td class="text-right">
<div class="font-semibold">${{ number_format($product->price ?? 0, 2) }}</div>
@if($product->unit)
<div class="text-xs text-base-content/50">per {{ $product->unit->name }}</div>
@endif
</td>
<td class="text-center">
@if($product->quantity_available > 0)
<span class="text-xs text-base-content/70">
{{ $product->quantity_available }} units
</span>
@else
<span class="text-xs text-base-content/40">Out of stock</span>
@endif
</td>
<td class="text-center">
@if($isSeller)
<!-- Disabled QTY input for sellers -->
<input type="number"
class="input input-bordered input-sm w-20 text-center"
value="0"
min="0"
disabled>
@else
<!-- Active QTY input for buyers -->
<input type="number"
class="input input-bordered input-sm w-20 text-center"
value="0"
min="0"
max="{{ $product->quantity_available }}"
data-product-id="{{ $product->id }}">
@endif
</td>
@if(!$isSeller)
<td class="text-right">
<div class="flex items-center justify-end gap-2">
<!-- Sample Button -->
<button class="btn btn-sm btn-ghost gap-1"
title="Request Sample"
onclick="requestSample({{ $product->id }})">
<span class="iconify lucide--flask-conical size-4"></span>
Sample
</button>
<!-- Message Button -->
<button class="btn btn-sm btn-ghost gap-1"
title="Message Seller"
onclick="messageSeller({{ $product->id }})">
<span class="iconify lucide--message-circle size-4"></span>
Message
</button>
<!-- Add to Cart Button -->
<button class="btn btn-sm btn-primary gap-2"
onclick="addToCart({{ $product->id }})">
<span class="iconify lucide--shopping-cart size-4"></span>
Add to Cart
</button>
</div>
</td>
@endif
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endforeach
@else
<div class="bg-base-100 border border-base-300">
<div class="text-center py-16">
<span class="iconify lucide--package size-12 text-base-content/20 mb-4 block"></span>
<h3 class="text-base font-medium text-base-content mb-2">No products available</h3>
<p class="text-sm text-base-content/60">This brand doesn't have any products listed yet.</p>
</div>
</div>
@endif
</div>
<!-- Share Menu Modal (Seller Only) -->
@if($isSeller)
<dialog id="share_menu_modal" class="modal">
<div class="modal-box max-w-lg">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-4">Share Menu</h3>
<div class="space-y-4">
<!-- Copy Link -->
<div>
<label class="label">
<span class="label-text">Share Link</span>
</label>
<div class="flex gap-2">
<input type="text"
id="share_link"
class="input input-bordered flex-1"
value="{{ route('buyer.brands.browse', [$business->slug, $brand->slug]) }}"
readonly>
<button class="btn btn-primary" onclick="copyShareLink()">
<span class="iconify lucide--copy size-4"></span>
Copy
</button>
</div>
</div>
<!-- Email Share -->
<div>
<label class="label">
<span class="label-text">Share via Email</span>
</label>
<div class="flex gap-2">
<input type="email"
id="share_email"
class="input input-bordered flex-1"
placeholder="buyer@example.com">
<button class="btn btn-primary" onclick="shareViaEmail()">
<span class="iconify lucide--mail size-4"></span>
Send
</button>
</div>
</div>
<!-- Download Menu -->
<div>
<label class="label">
<span class="label-text">Download Menu</span>
</label>
<button class="btn btn-outline w-full" onclick="downloadMenu()">
<span class="iconify lucide--download size-4"></span>
Download as PDF
</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@endif
<!-- About the Company Modal (Both Views) -->
<dialog id="about_company_modal" class="modal">
<div class="modal-box max-w-2xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-4">About {{ $brand->business->name }}</h3>
<div class="space-y-6">
<!-- Company Logo -->
@if($brand->business->logo_path)
<div class="flex justify-center mb-4">
<img src="{{ asset($brand->business->logo_path) }}"
alt="{{ $brand->business->name }}"
class="max-h-24 object-contain">
</div>
@endif
<!-- Company Description -->
@if($brand->business->description)
<div>
<h4 class="font-semibold mb-2">About</h4>
<p class="text-sm text-base-content/80 leading-relaxed">{{ $brand->business->description }}</p>
</div>
@endif
<!-- License Information -->
<div>
<h4 class="font-semibold mb-2">License Information</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
@if($brand->business->license_number)
<div>
<span class="text-base-content/60">License Number</span>
<p class="font-medium">{{ $brand->business->license_number }}</p>
</div>
@endif
@if($brand->business->license_type)
<div>
<span class="text-base-content/60">License Type</span>
<p class="font-medium">{{ strtoupper($brand->business->license_type) }}</p>
</div>
@endif
</div>
</div>
<!-- Contact Information -->
<div>
<h4 class="font-semibold mb-2">Contact Information</h4>
<div class="space-y-2 text-sm">
@if($brand->business->physical_address)
<div class="flex items-start gap-2">
<span class="iconify lucide--map-pin size-4 mt-0.5 text-base-content/60"></span>
<span>
{{ $brand->business->physical_address }}
@if($brand->business->physical_city || $brand->business->physical_state || $brand->business->physical_zipcode)
<br>{{ $brand->business->physical_city }}@if($brand->business->physical_city && $brand->business->physical_state), @endif{{ $brand->business->physical_state }} {{ $brand->business->physical_zipcode }}
@endif
</span>
</div>
@endif
@if($brand->business->business_phone)
<div class="flex items-center gap-2">
<span class="iconify lucide--phone size-4 text-base-content/60"></span>
<span>{{ $brand->business->business_phone }}</span>
</div>
@endif
@if($brand->business->business_email)
<div class="flex items-center gap-2">
<span class="iconify lucide--mail size-4 text-base-content/60"></span>
<a href="mailto:{{ $brand->business->business_email }}" class="hover:underline">
{{ $brand->business->business_email }}
</a>
</div>
@endif
</div>
</div>
<!-- Brands from this Company -->
@if($otherBrands->count() > 0 || true)
<div>
<h4 class="font-semibold mb-2">Brands</h4>
<div class="flex flex-wrap gap-2">
<span class="badge badge-lg">{{ $brand->name }}</span>
@foreach($otherBrands as $otherBrand)
<a href="@if($isSeller){{ route('seller.business.brands.preview', [$business->slug, $otherBrand->slug]) }}@else{{ route('buyer.brands.browse', [$business->slug, $otherBrand->slug]) }}@endif"
class="badge badge-lg badge-outline hover:badge-primary">
{{ $otherBrand->name }}
</a>
@endforeach
</div>
</div>
@endif
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@push('scripts')
<script>
// Share Menu Functions
function copyShareLink() {
const link = document.getElementById('share_link');
link.select();
document.execCommand('copy');
alert('Link copied to clipboard!');
}
function shareViaEmail() {
const email = document.getElementById('share_email').value;
if (!email) {
alert('Please enter an email address');
return;
}
// TODO: Implement email sharing functionality
alert('Email functionality coming soon!');
}
function downloadMenu() {
// TODO: Implement PDF download functionality
alert('PDF download coming soon!');
}
// Buyer Functions
@if(!$isSeller)
function requestSample(productId) {
// TODO: Implement sample request functionality
alert('Sample request for product ' + productId);
}
function messageSeller(productId) {
// TODO: Implement messaging functionality
alert('Message seller about product ' + productId);
}
function addToCart(productId) {
const qtyInput = document.querySelector(`input[data-product-id="${productId}"]`);
const quantity = parseInt(qtyInput.value) || 0;
if (quantity <= 0) {
alert('Please enter a quantity');
return;
}
// TODO: Implement add to cart functionality
alert(`Added ${quantity} units of product ${productId} to cart`);
}
@endif
</script>
@endpush
@endsection

View File

@@ -239,6 +239,81 @@
</div>
</div>
<!-- Invoice Statistics Cards -->
<div class="mt-6 grid gap-5 lg:grid-cols-2 xl:grid-cols-4">
<!-- Total Invoices -->
<div class="card bg-base-100 shadow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">Total Invoices</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold">{{ $invoiceStats['total_invoices'] }}</p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-2">
<span class="icon-[lucide--file-text] size-5"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">All time</p>
</div>
</div>
<!-- Paid Invoices -->
<div class="card bg-base-100 shadow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">Paid Invoices</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold">{{ $invoiceStats['paid_invoices'] }}</p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-2">
<span class="icon-[lucide--check-circle] size-5"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">Successfully collected</p>
</div>
</div>
<!-- Pending Invoices -->
<div class="card bg-base-100 shadow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">Pending Invoices</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold">{{ $invoiceStats['pending_invoices'] }}</p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-2">
<span class="icon-[lucide--clock] size-5"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">Awaiting payment</p>
</div>
</div>
<!-- Overdue Invoices -->
<div class="card bg-base-100 shadow">
<div class="card-body gap-2">
<div class="flex items-start justify-between gap-2 text-sm">
<div>
<p class="text-base-content/80 font-medium">Overdue Invoices</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold">{{ $invoiceStats['overdue_invoices'] }}</p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-2">
<span class="icon-[lucide--alert-circle] size-5"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">Past due date</p>
</div>
</div>
</div>
<!-- Next Steps Widget - Full Width -->
@if($progressSummary['completion_percentage'] < 100)
<div class="mt-6">
@@ -438,165 +513,151 @@
</div>
</div>
<!-- Low Stock Alerts -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center gap-2 mb-4">
<span class="icon-[lucide--triangle-alert] size-4 text-warning"></span>
<h3 class="font-medium">Low Stock Alerts</h3>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Blue Dream 1/8oz</p>
<p class="text-xs text-base-content/60">Only 5 units left</p>
</div>
<div class="badge badge-warning badge-sm">Low</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">OG Kush Pre-rolls</p>
<p class="text-xs text-base-content/60">Only 2 units left</p>
</div>
<div class="badge badge-error badge-sm">Critical</div>
</div>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="text-primary text-sm hover:underline">View all inventory </a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Orders and Top Products -->
<!-- Recent Invoices & Orders Tables -->
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2">
<!-- Recent Orders -->
<!-- Recent Invoices -->
<div class="card bg-base-100 shadow">
<div class="card-body p-0">
<div class="flex items-center gap-3 px-5 pt-5">
<span class="icon-[lucide--shopping-bag] size-4.5"></span>
<span class="font-medium">Recent Orders</span>
<button class="btn btn-outline border-base-300 btn-sm ms-auto">
<span class="icon-[lucide--download] size-3.5"></span>
Export
</button>
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="font-medium">Recent Invoices</h3>
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="text-sm text-primary hover:underline">
View All
</a>
</div>
<div class="mt-2 overflow-auto">
<table class="table table-zebra *:text-nowrap">
@if($recentInvoices->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Order Number</th>
<th>Invoice #</th>
<th>Customer</th>
<th>Product</th>
<th>Amount</th>
<th>Status</th>
<th>Due Date</th>
</tr>
</thead>
<tbody>
<tr>
<td class="font-mono text-sm">#CB-4829</td>
<td>Arizona Wellness</td>
<td>Blue Dream 1/8oz</td>
<td class="font-medium">$45.00</td>
@foreach($recentInvoices as $invoice)
<tr class="hover">
<td>
<div class="badge badge-success badge-sm">Delivered</div>
</td>
</tr>
<tr>
<td class="font-mono text-sm">#CB-4828</td>
<td>Green Valley Dispensary</td>
<td>OG Kush Pre-rolls (5pk)</td>
<td class="font-medium">$65.00</td>
<td>
<div class="badge badge-warning badge-sm">Processing</div>
</td>
</tr>
<tr>
<td class="font-mono text-sm">#CB-4827</td>
<td>Desert Bloom</td>
<td>White Widow Cartridge</td>
<td class="font-medium">$55.00</td>
<td>
<div class="badge badge-info badge-sm">Shipped</div>
</td>
</tr>
<tr>
<td class="font-mono text-sm">#CB-4826</td>
<td>Phoenix Relief Center</td>
<td>Mixed Strain Pack</td>
<td class="font-medium">$120.00</td>
<td>
<div class="badge badge-success badge-sm">Delivered</div>
<a href="{{ route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number]) }}" class="link link-primary font-medium">
{{ $invoice->invoice_number }}
</a>
</td>
<td>
<div class="flex items-center gap-2">
<div>
<div class="font-medium text-sm">{{ $invoice->business->name }}</div>
</div>
</div>
</td>
<td class="font-semibold">${{ number_format($invoice->amount_due / 100, 2) }}</td>
<td>
@if($invoice->payment_status === 'paid')
<span class="badge badge-ghost badge-sm">Paid</span>
@elseif($invoice->isOverdue())
<span class="badge badge-ghost badge-sm">Overdue</span>
@else
<span class="badge badge-ghost badge-sm">Pending</span>
@endif
</td>
<td class="text-sm text-base-content/60">
{{ $invoice->due_date ? $invoice->due_date->format('M j, Y') : '-' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[lucide--file-text] size-12 mx-auto mb-2 opacity-30"></span>
<p class="text-sm">No invoices yet</p>
</div>
@endif
</div>
</div>
<!-- Top Performing Products -->
<!-- Recent Orders -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-4">
<span class="icon-[lucide--trending-up] size-4.5"></span>
<span class="font-medium">Top Performing Products</span>
<div class="flex items-center justify-between mb-4">
<h3 class="font-medium">Recent Orders</h3>
<a href="{{ route('seller.business.orders.index', $business->slug) }}" class="text-sm text-primary hover:underline">
View All
</a>
</div>
<div class="space-y-4">
<div class="flex items-center gap-3">
<div class="bg-base-200 rounded-box w-10 h-10 flex items-center justify-center">
<span class="text-sm font-medium">1</span>
</div>
<div class="flex-1">
<p class="font-medium">Blue Dream 1/8oz</p>
<p class="text-sm text-base-content/60">154 units sold</p>
</div>
<div class="text-right">
<p class="font-medium">$6,930</p>
<p class="text-sm text-success">+23%</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-base-200 rounded-box w-10 h-10 flex items-center justify-center">
<span class="text-sm font-medium">2</span>
</div>
<div class="flex-1">
<p class="font-medium">OG Kush Pre-rolls</p>
<p class="text-sm text-base-content/60">132 units sold</p>
</div>
<div class="text-right">
<p class="font-medium">$5,280</p>
<p class="text-sm text-success">+18%</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-base-200 rounded-box w-10 h-10 flex items-center justify-center">
<span class="text-sm font-medium">3</span>
</div>
<div class="flex-1">
<p class="font-medium">White Widow Cartridge</p>
<p class="text-sm text-base-content/60">98 units sold</p>
</div>
<div class="text-right">
<p class="font-medium">$4,410</p>
<p class="text-sm text-success">+15%</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-base-200 rounded-box w-10 h-10 flex items-center justify-center">
<span class="text-sm font-medium">4</span>
</div>
<div class="flex-1">
<p class="font-medium">Gorilla Glue Edibles</p>
<p class="text-sm text-base-content/60">87 units sold</p>
</div>
<div class="text-right">
<p class="font-medium">$3,045</p>
<p class="text-sm text-warning">+8%</p>
</div>
</div>
@php
// Get recent orders for this business (seller perspective)
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
$recentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->latest()
->take(5)
->pluck('order_id')
->unique();
$recentOrders = \App\Models\Order::with('business')
->whereIn('id', $recentOrderIds)
->latest()
->take(5)
->get();
@endphp
@if($recentOrders->count() > 0)
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@foreach($recentOrders as $order)
<tr class="hover">
<td>
<a href="{{ route('seller.business.orders.show', [$business->slug, $order->order_number]) }}" class="link link-primary font-medium">
#{{ $order->order_number }}
</a>
</td>
<td>
<div class="flex items-center gap-2">
<div>
<div class="font-medium text-sm">{{ $order->business->name }}</div>
</div>
</div>
</td>
<td class="font-semibold">${{ number_format($order->total / 100, 2) }}</td>
<td>
<span class="badge badge-ghost badge-sm">{{ ucfirst($order->status) }}</span>
</td>
<td class="text-sm text-base-content/60">
{{ $order->created_at->format('M j, Y') }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base-content/60">
<span class="icon-[lucide--package] size-12 mx-auto mb-2 opacity-30"></span>
<p class="text-sm">No orders yet</p>
</div>
@endif
</div>
</div>
</div>
</div>
@endsection

File diff suppressed because it is too large Load Diff

View File

@@ -13,15 +13,135 @@
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Brand Management</h2>
<p class="text-base-content/60">Manage brands associated with this company.</p>
<!-- Add Brand Button -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-base-content flex items-center gap-2">
<span class="iconify lucide--package size-8"></span>
Brands
</h1>
<p class="text-base-content/60 mt-1">Manage brands associated with {{ $business->name }}</p>
</div>
<div>
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary">
<span class="iconify lucide--plus size-4.5"></span>
Add Brand
</a>
</div>
</div>
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
</div>
<!-- Brands Table -->
<div class="mt-6">
<div class="card bg-base-100 shadow overflow-visible">
<div class="card-body p-0 overflow-visible">
@if ($brands->isEmpty())
<div class="p-16 text-center">
<span class="iconify lucide--package text-base-content/30 mx-auto size-16"></span>
<h3 class="mt-4 text-lg font-medium text-base-content">No brands</h3>
<p class="mt-2 text-sm text-base-content/60">Get started by creating your first brand.</p>
<div class="mt-6">
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary">
<span class="iconify lucide--plus size-4.5"></span>
Create Brand
</a>
</div>
</div>
@else
<div class="overflow-x-auto overflow-y-visible">
<table class="table table-zebra">
<thead>
<tr>
<th>Image</th>
<th>Brand Name</th>
<th>Brand URL</th>
<th>Status</th>
<th>Products</th>
<th>Visibility</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach ($brands as $brand)
<tr>
<td>
@if ($brand->hasLogo())
<div class="avatar">
<div class="mask mask-squircle w-10 h-10">
<img src="{{ $brand->getLogoUrl() }}"
alt="{{ $brand->name }}" />
</div>
</div>
@else
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content mask mask-squircle w-10">
<span class="text-xl">{{ substr($brand->name, 0, 1) }}</span>
</div>
</div>
@endif
</td>
<td>
<div class="font-medium">{{ $brand->name }}</div>
@if ($brand->tagline)
<div class="text-sm text-base-content/60">{{ $brand->tagline }}</div>
@endif
</td>
<td>
@if ($brand->website_url)
<a href="{{ $brand->website_url }}" target="_blank" class="link link-primary text-sm">
{{ $brand->website_url }}
</a>
@else
<span class="text-base-content/40 text-sm"></span>
@endif
</td>
<td>
@if ($brand->is_active)
<div class="badge badge-ghost gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" class="inline-block h-4 w-4 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z">
</path>
</svg>
Active
</div>
@else
<div class="badge badge-ghost gap-2">
Inactive
</div>
@endif
</td>
<td>
<span class="text-sm">{{ $brand->products()->count() }} products</span>
</td>
<td>
@if ($brand->is_public)
<div class="badge badge-outline">Public</div>
@else
<div class="badge badge-ghost">Private</div>
@endif
@if ($brand->is_featured)
<div class="badge badge-ghost">Featured</div>
@endif
</td>
<td>
<div class="flex gap-2 justify-end">
<a href="{{ route('seller.business.brands.preview', [$business->slug, $brand]) }}" target="_blank" class="btn btn-primary btn-sm">
<span class="iconify lucide--eye size-4"></span>
Preview
</a>
<a href="{{ route('seller.business.brands.edit', [$business->slug, $brand]) }}" class="btn btn-outline btn-sm">
<span class="iconify lucide--pencil size-4"></span>
Edit Brand
</a>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
</div>

View File

@@ -2,27 +2,379 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Company Information</p>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Company Information</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Company Information</li>
<li><a href="{{ route('seller.business.settings.company-information', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Company Information</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Company Information Settings</h2>
<p class="text-base-content/60">Manage your company details, DBA, address, and other information.</p>
<form action="{{ route('seller.business.settings.company-information.update', $business->slug) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<!-- Company Overview -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Company Overview</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Company Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name', $business->name) }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="Your company name" required>
@error('name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- DBA Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">DBA Name</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="'Doing Business As' name if different from legal name">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input type="text" name="dba_name" value="{{ old('dba_name', $business->dba_name) }}"
class="input input-bordered @error('dba_name') input-error @enderror"
placeholder="Trade name or DBA">
@error('dba_name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Description (Full Width - Spans 2 columns) -->
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Company Description</span>
<span class="label-text-alt flex items-center gap-2">
<span class="tooltip tooltip-left" data-tip="This appears in the About Company modal">
<span class="icon-[lucide--info] size-4"></span>
</span>
<span class="text-sm" id="description-count">0/500</span>
</span>
</label>
<textarea name="description"
rows="4"
class="textarea textarea-bordered w-full resize-none @error('description') textarea-error @enderror"
placeholder="Describe your company, mission, and what makes you unique"
maxlength="500"
oninput="updateCharCount('description', 'description-count', 500)">{{ old('description', $business->description) }}</textarea>
@error('description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
<!-- Company Branding -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Company Branding</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Company Logo -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Logo</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="Displayed in About Company modal">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
@if($business->logo_path && \Storage::disk('public')->exists($business->logo_path))
<div class="mb-2">
<img src="{{ asset('storage/' . $business->logo_path) }}" alt="Company logo" class="w-32 h-32 rounded-lg border border-base-300 object-contain bg-base-50 p-2">
</div>
@endif
<input type="file" name="logo" accept="image/*"
class="file-input file-input-bordered @error('logo') file-input-error @enderror"
onchange="previewImage(this, 'logo-preview')">
<label class="label">
<span class="label-text-alt">Max 2MB. Recommended: Square (512x512px)</span>
</label>
@error('logo')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="logo-preview" class="mt-2 hidden">
<p class="text-sm text-base-content/60 mb-1">Preview:</p>
<img src="" alt="Logo preview" class="w-32 h-32 rounded-lg border border-base-300 object-contain bg-base-50 p-2">
</div>
@if($business->logo_path && \Storage::disk('public')->exists($business->logo_path))
<label class="label cursor-pointer justify-start gap-2 mt-2">
<input type="checkbox" name="remove_logo" value="1" class="checkbox checkbox-sm">
<span class="label-text text-sm">Remove current logo</span>
</label>
@endif
</div>
<!-- Company Banner -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Banner</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="Displayed as hero banner on brand preview pages">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
@if($business->banner_path && \Storage::disk('public')->exists($business->banner_path))
<div class="mb-2">
<img src="{{ asset('storage/' . $business->banner_path) }}" alt="Company banner" class="w-full h-24 rounded-lg border border-base-300 object-cover">
</div>
@endif
<input type="file" name="banner" accept="image/*"
class="file-input file-input-bordered @error('banner') file-input-error @enderror"
onchange="previewImage(this, 'banner-preview')">
<label class="label">
<span class="label-text-alt">Max 4MB. Recommended: 1920x640px (3:1 ratio)</span>
</label>
@error('banner')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="banner-preview" class="mt-2 hidden">
<p class="text-sm text-base-content/60 mb-1">Preview:</p>
<img src="" alt="Banner preview" class="w-full h-24 rounded-lg border border-base-300 object-cover">
</div>
@if($business->banner_path && \Storage::disk('public')->exists($business->banner_path))
<label class="label cursor-pointer justify-start gap-2 mt-2">
<input type="checkbox" name="remove_banner" value="1" class="checkbox checkbox-sm">
<span class="label-text text-sm">Remove current banner</span>
</label>
@endif
</div>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Contact Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Business Phone -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Business Phone</span>
</label>
<input type="tel" name="business_phone" value="{{ old('business_phone', $business->business_phone) }}"
class="input input-bordered @error('business_phone') input-error @enderror"
placeholder="(555) 123-4567">
@error('business_phone')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Business Email -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Business Email</span>
</label>
<input type="email" name="business_email" value="{{ old('business_email', $business->business_email) }}"
class="input input-bordered @error('business_email') input-error @enderror"
placeholder="info@company.com">
@error('business_email')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Physical Address -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Physical Address</h2>
<div class="grid grid-cols-12 gap-4">
<!-- Street Address -->
<div class="form-control col-span-12 md:col-span-8">
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<input type="text" name="physical_address" value="{{ old('physical_address', $business->physical_address) }}"
class="input input-bordered w-full @error('physical_address') input-error @enderror"
placeholder="123 Main Street">
@error('physical_address')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Suite/Unit Number -->
<div class="form-control col-span-12 md:col-span-4">
<label class="label">
<span class="label-text font-medium">Suite/Unit</span>
</label>
<input type="text" name="physical_suite" value="{{ old('physical_suite', $business->physical_suite) }}"
class="input input-bordered w-full @error('physical_suite') input-error @enderror"
placeholder="Suite 100">
@error('physical_suite')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- City -->
<div class="form-control col-span-12 md:col-span-6">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input type="text" name="physical_city" value="{{ old('physical_city', $business->physical_city) }}"
class="input input-bordered w-full @error('physical_city') input-error @enderror"
placeholder="Phoenix">
@error('physical_city')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- State -->
<div class="form-control col-span-12 md:col-span-3">
<label class="label">
<span class="label-text font-medium">State</span>
</label>
<input type="text" name="physical_state" value="{{ old('physical_state', $business->physical_state) }}"
class="input input-bordered w-full @error('physical_state') input-error @enderror"
placeholder="AZ"
maxlength="2">
@error('physical_state')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- ZIP Code -->
<div class="form-control col-span-12 md:col-span-3">
<label class="label">
<span class="label-text font-medium">ZIP Code</span>
</label>
<input type="text" name="physical_zipcode" value="{{ old('physical_zipcode', $business->physical_zipcode) }}"
class="input input-bordered w-full @error('physical_zipcode') input-error @enderror"
placeholder="85001">
@error('physical_zipcode')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- License Information -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">License Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- License Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">License Number</span>
</label>
<input type="text" name="license_number" value="{{ old('license_number', $business->license_number) }}"
class="input input-bordered @error('license_number') input-error @enderror"
placeholder="AZ-MED-00001234">
@error('license_number')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- License Type -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">License Type</span>
</label>
<select name="license_type" class="select select-bordered @error('license_type') select-error @enderror">
<option value="">Select license type</option>
<option value="medical" {{ old('license_type', $business->license_type) == 'medical' ? 'selected' : '' }}>Medical</option>
<option value="adult-use" {{ old('license_type', $business->license_type) == 'adult-use' ? 'selected' : '' }}>Adult Use</option>
<option value="both" {{ old('license_type', $business->license_type) == 'both' ? 'selected' : '' }}>Both</option>
</select>
@error('license_type')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Changes
</button>
</div>
</form>
@endsection
@push('scripts')
<script>
// Character counter
function updateCharCount(textareaName, counterId, maxLength) {
const textarea = document.querySelector(`[name="${textareaName}"]`);
const counter = document.getElementById(counterId);
if (textarea && counter) {
counter.textContent = `${textarea.value.length}/${maxLength}`;
}
}
// Image preview
function previewImage(input, previewId) {
const preview = document.getElementById(previewId);
const img = preview.querySelector('img');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
img.src = e.target.result;
preview.classList.remove('hidden');
}
reader.readAsDataURL(input.files[0]);
}
}
// Initialize character counters on page load
document.addEventListener('DOMContentLoaded', function() {
updateCharCount('description', 'description-count', 500);
});
</script>
@endpush

View File

@@ -2,27 +2,145 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Invoices</p>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Invoice Settings</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Invoices</li>
<li><a href="{{ route('seller.business.settings.invoices', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Invoices</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Invoice Settings</h2>
<p class="text-base-content/60">Configure invoice templates and preferences.</p>
<form action="{{ route('seller.business.settings.invoices.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<!-- Combined Use Payable to Info -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-2">Combined Use Payable to Info</h2>
<p class="text-sm text-base-content/60 mb-4">Accounts Payable information for your Company's Combined or Cannabis license orders.<br>If not entered, the default Company name and address will be used.</p>
<div class="space-y-6">
<!-- Company Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Name</span>
</label>
<input
type="text"
name="invoice_payable_company_name"
value="{{ old('invoice_payable_company_name', $business->invoice_payable_company_name) }}"
class="input input-bordered @error('invoice_payable_company_name') input-error @enderror"
placeholder="Life Changers Investments DBA Leopard AZ"
/>
@error('invoice_payable_company_name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Address -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Address</span>
</label>
<input
type="text"
name="invoice_payable_address"
value="{{ old('invoice_payable_address', $business->invoice_payable_address) }}"
class="input input-bordered @error('invoice_payable_address') input-error @enderror"
placeholder="1225 W Deer Valley"
/>
@error('invoice_payable_address')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- City -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input
type="text"
name="invoice_payable_city"
value="{{ old('invoice_payable_city', $business->invoice_payable_city) }}"
class="input input-bordered @error('invoice_payable_city') input-error @enderror"
placeholder="Phoenix"
/>
@error('invoice_payable_city')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- State -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">State</span>
</label>
<select
name="invoice_payable_state"
class="select select-bordered @error('invoice_payable_state') select-error @enderror"
>
<option value="">Select state</option>
<option value="AZ" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'AZ' ? 'selected' : '' }}>Arizona</option>
<option value="CA" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'CA' ? 'selected' : '' }}>California</option>
<option value="CO" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'CO' ? 'selected' : '' }}>Colorado</option>
<option value="NV" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'NV' ? 'selected' : '' }}>Nevada</option>
<option value="NM" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'NM' ? 'selected' : '' }}>New Mexico</option>
<option value="OR" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'OR' ? 'selected' : '' }}>Oregon</option>
<option value="WA" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'WA' ? 'selected' : '' }}>Washington</option>
</select>
@error('invoice_payable_state')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Zip Code -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Zip Code</span>
</label>
<input
type="text"
name="invoice_payable_zipcode"
value="{{ old('invoice_payable_zipcode', $business->invoice_payable_zipcode) }}"
class="input input-bordered @error('invoice_payable_zipcode') input-error @enderror"
placeholder="85027"
maxlength="10"
/>
@error('invoice_payable_zipcode')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Settings
</button>
</div>
</form>
@endsection

View File

@@ -2,27 +2,264 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Notifications</p>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Email Settings</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Notifications</li>
<li><a href="{{ route('seller.business.settings.notifications', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Notifications</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Notification Preferences</h2>
<p class="text-base-content/60">Configure email and system notification preferences.</p>
<p class="text-sm text-base-content/60 mb-6">Customize email notification settings.</p>
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<form action="{{ route('seller.business.settings.notifications.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<!-- New Order Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">New Order Email Notifications</h2>
<div class="space-y-4">
<!-- Email List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Comma-separated email addresses to notify when a new order is placed">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="new_order_email_notifications"
value="{{ old('new_order_email_notifications', $business->new_order_email_notifications) }}"
class="input input-bordered @error('new_order_email_notifications') input-error @enderror"
placeholder="email1@example.com, email2@example.com"
/>
@error('new_order_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Conditional Options -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="new_order_only_when_no_sales_rep"
value="1"
class="checkbox checkbox-primary"
{{ old('new_order_only_when_no_sales_rep', $business->new_order_only_when_no_sales_rep) ? 'checked' : '' }}
/>
<span class="label-text">Only send New Order Email notifications when no sales reps are assigned to the buyer's account.</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="new_order_do_not_send_to_admins"
value="1"
class="checkbox checkbox-primary"
{{ old('new_order_do_not_send_to_admins', $business->new_order_do_not_send_to_admins) ? 'checked' : '' }}
/>
<span class="label-text">Do not send notifications to company admins.</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Order Accepted Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Accepted Email Notifications</h2>
<div class="space-y-4">
<!-- Email List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Notify fulfillment and warehouse teams when an order is accepted">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="order_accepted_email_notifications"
value="{{ old('order_accepted_email_notifications', $business->order_accepted_email_notifications) }}"
class="input input-bordered @error('order_accepted_email_notifications') input-error @enderror"
placeholder="fulfillment@example.com, warehouse@example.com"
/>
@error('order_accepted_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Enable Shipped Emails For Sales Reps -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enable_shipped_emails_for_sales_reps"
value="1"
class="checkbox checkbox-primary"
{{ old('enable_shipped_emails_for_sales_reps', $business->enable_shipped_emails_for_sales_reps) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Enable Shipped Emails For Sales Reps</span>
<p class="text-xs text-base-content/60 mt-1">When checked, sales reps assigned to a customer will receive an email when an order for one of their customers is marked Shipped</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Platform Inquiry Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Platform Inquiry Email Notifications</h2>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Sales reps always get notified. If blank and no sales reps exist, admins are notified.">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="platform_inquiry_email_notifications"
value="{{ old('platform_inquiry_email_notifications', $business->platform_inquiry_email_notifications) }}"
class="input input-bordered @error('platform_inquiry_email_notifications') input-error @enderror"
placeholder="sales@example.com"
/>
@error('platform_inquiry_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Manual Order Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Manual Order Email Notifications</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enable_manual_order_email_notifications"
value="1"
class="checkbox checkbox-primary"
{{ old('enable_manual_order_email_notifications', $business->enable_manual_order_email_notifications) ? 'checked' : '' }}
/>
<span class="label-text font-medium">Enable Manual Order Email Notifications</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="When enabled, all the same emails sent for buyer-created orders will also be sent for orders you create. When disabled, notifications are only sent for buyer-created orders.">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="manual_order_emails_internal_only"
value="1"
class="checkbox checkbox-primary"
{{ old('manual_order_emails_internal_only', $business->manual_order_emails_internal_only) ? 'checked' : '' }}
/>
<span class="label-text font-medium">Manual Order Emails Internal Only</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Email notifications for manual orders will be sent to internal recipients only and not to buyers">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Low Inventory Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Low Inventory Email Notifications</h2>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Notify these addresses when inventory levels are low">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="low_inventory_email_notifications"
value="{{ old('low_inventory_email_notifications', $business->low_inventory_email_notifications) }}"
class="input input-bordered @error('low_inventory_email_notifications') input-error @enderror"
placeholder="inventory@example.com"
/>
@error('low_inventory_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Certified Seller Status Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Certified Seller Status Email Notifications</h2>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Notify these addresses when certified seller status changes">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="certified_seller_status_email_notifications"
value="{{ old('certified_seller_status_email_notifications', $business->certified_seller_status_email_notifications) }}"
class="input input-bordered @error('certified_seller_status_email_notifications') input-error @enderror"
placeholder="admin@example.com"
/>
@error('certified_seller_status_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Settings
</button>
</div>
</form>
@endsection

View File

@@ -2,27 +2,331 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Orders</p>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Order Settings</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Orders</li>
<li><a href="{{ route('seller.business.settings.orders', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Orders</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Order Settings</h2>
<p class="text-base-content/60">Configure order processing settings and preferences.</p>
<form action="{{ route('seller.business.settings.orders.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<!-- Order Preferences -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Preferences</h2>
<div class="space-y-4">
<!-- Separate Orders by Brand -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="separate_orders_by_brand"
value="1"
class="checkbox checkbox-primary"
{{ old('separate_orders_by_brand', $business->separate_orders_by_brand) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Separate Orders by Brand</span>
<p class="text-xs text-base-content/60">Create individual orders for each brand in multi-brand purchases</p>
</div>
</label>
</div>
<!-- Auto Increment Order IDs -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="auto_increment_order_ids"
value="1"
class="checkbox checkbox-primary"
{{ old('auto_increment_order_ids', $business->auto_increment_order_ids) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Auto Increment Order IDs</span>
<p class="text-xs text-base-content/60">Automatically generate sequential order numbers</p>
</div>
</label>
</div>
<!-- Show Mark as Paid -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="show_mark_as_paid"
value="1"
class="checkbox checkbox-primary"
{{ old('show_mark_as_paid', $business->show_mark_as_paid ?? true) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Show Mark as Paid</span>
<p class="text-xs text-base-content/60">Display "Mark as Paid" option in order management</p>
</div>
</label>
</div>
<!-- Display CRM License on Orders -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="display_crm_license_on_orders"
value="1"
class="checkbox checkbox-primary"
{{ old('display_crm_license_on_orders', $business->display_crm_license_on_orders) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Display CRM License on Orders</span>
<p class="text-xs text-base-content/60">Show business license number on order documents</p>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Financial Settings -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Financial Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Order Minimum -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Order Minimum</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="order_minimum"
value="{{ old('order_minimum', $business->order_minimum) }}"
class="input input-bordered w-full @error('order_minimum') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Minimum order amount required</span>
</label>
@error('order_minimum')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Default Shipping Charge -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Default Shipping Charge</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="default_shipping_charge"
value="{{ old('default_shipping_charge', $business->default_shipping_charge) }}"
class="input input-bordered w-full @error('default_shipping_charge') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Standard shipping fee per order</span>
</label>
@error('default_shipping_charge')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Free Shipping Minimum -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Free Shipping Minimum</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="free_shipping_minimum"
value="{{ old('free_shipping_minimum', $business->free_shipping_minimum) }}"
class="input input-bordered w-full @error('free_shipping_minimum') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Order amount for free shipping</span>
</label>
@error('free_shipping_minimum')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Order Documents -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Documents</h2>
<!-- Order Disclaimer -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Order Disclaimer</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea
name="order_disclaimer"
rows="4"
class="textarea textarea-bordered @error('order_disclaimer') textarea-error @enderror"
placeholder="Enter any disclaimer text to appear on orders..."
>{{ old('order_disclaimer', $business->order_disclaimer) }}</textarea>
<label class="label">
<span class="label-text-alt">Displayed on order confirmations and invoices</span>
</label>
@error('order_disclaimer')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Order Invoice Footer -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Order Invoice Footer Copy</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea
name="order_invoice_footer"
rows="3"
class="textarea textarea-bordered @error('order_invoice_footer') textarea-error @enderror"
placeholder="Enter footer text for invoices..."
>{{ old('order_invoice_footer', $business->order_invoice_footer) }}</textarea>
<label class="label">
<span class="label-text-alt">Appears at the bottom of all invoices</span>
</label>
@error('order_invoice_footer')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Order Management -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Management</h2>
<!-- Prevent Order Editing -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Prevent Order Editing</span>
</label>
<select
name="prevent_order_editing"
class="select select-bordered @error('prevent_order_editing') select-error @enderror"
>
<option value="never" {{ old('prevent_order_editing', $business->prevent_order_editing ?? 'never') == 'never' ? 'selected' : '' }}>
Never - Always allow editing
</option>
<option value="after_approval" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'after_approval' ? 'selected' : '' }}>
After Approval - Lock once approved
</option>
<option value="after_fulfillment" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'after_fulfillment' ? 'selected' : '' }}>
After Fulfillment - Lock once fulfilled
</option>
<option value="always" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'always' ? 'selected' : '' }}>
Always - Prevent all editing
</option>
</select>
<label class="label">
<span class="label-text-alt">Control when orders can no longer be edited</span>
</label>
@error('prevent_order_editing')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Arizona Compliance Features -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Arizona Compliance Features</h2>
<div class="space-y-4">
<!-- Require Patient Count -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="az_require_patient_count"
value="1"
class="checkbox checkbox-primary"
{{ old('az_require_patient_count', $business->az_require_patient_count) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Require Patient Count</span>
<p class="text-xs text-base-content/60">Require customer to provide patient count with orders (medical licenses)</p>
</div>
</label>
</div>
<!-- Require Allotment Verification -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="az_require_allotment_verification"
value="1"
class="checkbox checkbox-primary"
{{ old('az_require_allotment_verification', $business->az_require_allotment_verification) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Require Allotment Verification</span>
<p class="text-xs text-base-content/60">Verify customer allotment availability before order confirmation</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Settings
</button>
</div>
</form>
@endsection

View File

@@ -2,27 +2,784 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Users</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Users</li>
</ul>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Manage Users</h1>
<p class="text-sm text-base-content/60 mt-1">Manage the permissions for your users.</p>
</div>
<button type="button" class="btn btn-primary gap-2" onclick="add_user_modal.showModal()">
<span class="icon-[lucide--plus] size-4"></span>
Add users
</button>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">User Management</h2>
<p class="text-base-content/60">Manage users and permissions for your business.</p>
<!-- Search and Filter Section -->
<form method="GET" action="{{ route('seller.business.settings.users', $business->slug) }}" class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
<!-- Search -->
<div class="md:col-span-5">
<label class="label">
<span class="label-text font-medium">Search</span>
</label>
<div class="join w-full">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search by name or email"
class="input input-bordered join-item w-full"
/>
<button type="submit" class="btn btn-primary join-item">
<span class="icon-[lucide--search] size-4"></span>
</button>
</div>
</div>
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<!-- Empty spacer -->
<div class="md:col-span-2"></div>
<!-- Account Type -->
<div class="md:col-span-2">
<label class="label">
<span class="label-text font-medium">Account type</span>
</label>
<select name="account_type" class="select select-bordered w-full">
<option value="">All types</option>
<option value="company-owner" {{ request('account_type') === 'company-owner' ? 'selected' : '' }}>Owner</option>
<option value="company-manager" {{ request('account_type') === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-user" {{ request('account_type') === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ request('account_type') === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ request('account_type') === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ request('account_type') === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ request('account_type') === 'company-processing' ? 'selected' : '' }}>Processing</option>
</select>
</div>
<!-- Last Login Start Date -->
<div class="md:col-span-3">
<label class="label">
<span class="label-text font-medium">Last login</span>
</label>
<div class="relative">
<input
type="date"
name="last_login_start"
value="{{ request('last_login_start') }}"
class="input input-bordered w-full pr-10"
/>
<span class="icon-[lucide--calendar] size-4 absolute right-3 top-1/2 -translate-y-1/2 text-base-content/60 pointer-events-none"></span>
</div>
<label class="label">
<span class="label-text-alt text-xs">Start Date</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-12 gap-4 mt-4">
<!-- Empty spacer -->
<div class="md:col-span-7"></div>
<!-- Last Login End Date -->
<div class="md:col-span-3">
<label class="label">
<span class="label-text font-medium">Last login</span>
</label>
<div class="relative">
<input
type="date"
name="last_login_end"
value="{{ request('last_login_end') }}"
class="input input-bordered w-full pr-10"
/>
<span class="icon-[lucide--calendar] size-4 absolute right-3 top-1/2 -translate-y-1/2 text-base-content/60 pointer-events-none"></span>
</div>
<label class="label">
<span class="label-text-alt text-xs">End Date</span>
</label>
</div>
<!-- Action Buttons -->
<div class="md:col-span-2 flex items-end gap-2">
<a href="{{ route('seller.business.settings.users', $business->slug) }}" class="btn btn-ghost flex-1">Clear</a>
<button type="submit" class="btn btn-primary flex-1">Apply</button>
</div>
</div>
</div>
</div>
</form>
<!-- Users Table -->
@if($users->count() > 0)
<div class="card bg-base-100 border border-base-300">
<div class="overflow-x-auto">
<table class="table table-lg">
<thead class="bg-base-200">
<tr>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--user] size-4"></span>
Name
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--mail] size-4"></span>
Email
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--shield] size-4"></span>
Role
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--clock] size-4"></span>
Last Login
</div>
</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr class="hover:bg-base-200/50 transition-colors">
<td>
<div class="font-semibold">{{ $user->name }}</div>
</td>
<td>
<div class="text-sm">{{ $user->email }}</div>
</td>
<td>
@if($user->roles->isNotEmpty())
@php
$roleName = $user->roles->first()->name;
$displayName = match($roleName) {
'company-owner' => 'Owner',
'company-manager' => 'Manager',
'company-user' => 'Staff',
'company-sales' => 'Sales',
'company-accounting' => 'Accounting',
'company-manufacturing' => 'Manufacturing',
'company-processing' => 'Processing',
'buyer-owner' => 'Buyer Owner',
'buyer-manager' => 'Buyer Manager',
'buyer-user' => 'Buyer Staff',
default => ucwords(str_replace('-', ' ', $roleName))
};
@endphp
<div class="badge badge-ghost badge-sm">
{{ $displayName }}
</div>
@else
<span class="text-base-content/40"></span>
@endif
</td>
<td>
@if($user->last_login_at)
<div class="text-sm">{{ $user->last_login_at->format('M d, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $user->last_login_at->format('g:i A') }}</div>
@else
<span class="text-base-content/40">Never</span>
@endif
</td>
<td>
<div class="flex gap-2 justify-end">
<button type="button" class="btn btn-sm btn-ghost gap-2" onclick="openEditModal{{ $user->id }}()">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
@if($users->hasPages())
<div class="flex justify-center border-t border-base-300 p-4 bg-base-50">
{{ $users->links() }}
</div>
@endif
</div>
@else
<!-- Empty State -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="text-center py-8 text-base-content/60">
<span class="icon-[lucide--users] size-12 mx-auto mb-2 opacity-30"></span>
<p class="text-sm">
@if(request()->hasAny(['search', 'account_type', 'last_login_start', 'last_login_end']))
No users match your filters. Try adjusting your search criteria.
@else
No users found. Add your first user to get started.
@endif
</p>
</div>
</div>
</div>
@endif
<!-- Add User Modal -->
<dialog id="add_user_modal" class="modal">
<div class="modal-box max-w-2xl max-h-[90vh] overflow-y-auto">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-6">Add New User</h3>
<form method="POST" action="{{ route('seller.business.settings.users.invite', $business->slug) }}">
@csrf
<!-- Account Information Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Information</h4>
<div class="space-y-4">
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
name="email"
required
class="input input-bordered w-full"
placeholder="user@example.com"
/>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">Add a new or existing user</span>
</label>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input
type="text"
name="first_name"
required
class="input input-bordered w-full"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input
type="text"
name="last_name"
required
class="input input-bordered w-full"
/>
</div>
</div>
<!-- Phone Number -->
<div>
<label class="label">
<span class="label-text font-medium">Phone number</span>
</label>
<input
type="tel"
name="phone"
class="input input-bordered w-full"
placeholder="(XXX) XXX-XXXX"
/>
</div>
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
</label>
<input
type="text"
name="position"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
/>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div class="grid grid-cols-2 gap-3">
<label class="cursor-pointer">
<input type="radio" name="role" value="company-user" class="peer sr-only" checked />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Staff</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-sales" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Sales</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-accounting" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Accounting</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manufacturing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manufacturing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-processing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Processing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manager" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manager</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-owner" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Owner</div>
</div>
</label>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible. If the user is a sales rep, you cannot disable this setting.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Note about permissions -->
<div class="alert bg-base-200 border-base-300 mb-6">
<span class="icon-[lucide--info] size-5 text-base-content/60"></span>
<div class="text-sm">
<p class="font-semibold">Role-based Access</p>
<p class="text-base-content/70">Permissions are determined by the selected account type. Granular permission controls will be available in a future update.</p>
</div>
</div>
<div class="modal-action">
<button type="button" onclick="add_user_modal.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
Add user
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Edit User Modals (one per user) -->
@foreach($users as $user)
@php
$nameParts = explode(' ', $user->name, 2);
$firstName = $nameParts[0] ?? '';
$lastName = $nameParts[1] ?? '';
$userRole = $user->roles->first()?->name ?? 'company-user';
$pivot = $user->pivot ?? null;
$isPointOfContact = $pivot && $pivot->contact_type === 'primary';
@endphp
<dialog id="edit_user_modal_{{ $user->id }}" class="modal">
<div class="modal-box max-w-4xl h-[90vh] flex flex-col p-0">
<div class="flex-shrink-0 p-6 pb-4 border-b border-base-300">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg">Edit User</h3>
</div>
<form method="POST" action="{{ route('seller.business.settings.users.update', ['business' => $business->slug, 'user' => $user->id]) }}" class="flex flex-col flex-1 min-h-0">
@csrf
@method('PATCH')
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Account Information Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Information</h4>
<div class="space-y-4">
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
name="email"
value="{{ $user->email }}"
required
class="input input-bordered w-full"
/>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input
type="text"
name="first_name"
value="{{ $firstName }}"
required
class="input input-bordered w-full"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input
type="text"
name="last_name"
value="{{ $lastName }}"
required
class="input input-bordered w-full"
/>
</div>
</div>
<!-- Phone Number -->
<div>
<label class="label">
<span class="label-text font-medium">Phone number</span>
</label>
<input
type="tel"
name="phone"
value="{{ $user->phone }}"
class="input input-bordered w-full"
placeholder="(XXX) XXX-XXXX"
/>
</div>
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
</label>
<input
type="text"
name="position"
value="{{ $pivot->position ?? '' }}"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
/>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div>
<label class="label">
<span class="label-text font-medium">Role</span>
</label>
<select name="role" class="select select-bordered w-full" required>
<option value="company-user" {{ $userRole === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ $userRole === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ $userRole === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ $userRole === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ $userRole === 'company-processing' ? 'selected' : '' }}>Processing</option>
<option value="company-manager" {{ $userRole === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-owner" {{ $userRole === 'company-owner' ? 'selected' : '' }}>Owner</option>
</select>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" {{ $isPointOfContact ? 'checked' : '' }} />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Permissions Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base flex items-center gap-2">
<span class="icon-[lucide--shield-check] size-5"></span>
Permissions
</h4>
<!-- Order & Inventory Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[lucide--package] size-5"></span>
<h5 class="font-semibold">Order & Inventory Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_inventory" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage inventory</span>
<p class="text-xs text-base-content/60 mt-0.5">Create, edit, and archive products and varieties</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="edit_prices" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Edit prices</span>
<p class="text-xs text-base-content/60 mt-0.5">Manipulate product pricing and apply blanket discounts</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_orders_received" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Orders Received</span>
<p class="text-xs text-base-content/60 mt-0.5">Update order statuses, create manual orders</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_billing" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage billing</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage billing information for LeafLink fees (Admin only)</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Customer Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[lucide--users] size-5"></span>
<h5 class="font-semibold">Customer Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Customers and Contacts</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage customer records, apply discounts and shipping charges</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_sales_reports" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access sales reports</span>
<p class="text-xs text-base-content/60 mt-0.5">Access and download all sales reports and dashboards</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="export_crm" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Export CRM</span>
<p class="text-xs text-base-content/60 mt-0.5">Export customers/contacts as a CSV file</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Logistics -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--truck] size-5"></span>
<h5 class="font-semibold">Logistics</h5>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_fulfillment" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage fulfillment</span>
<p class="text-xs text-base-content/60 mt-0.5">Access to Fulfillment & Shipment pages and update statuses</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Email -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--mail] size-5"></span>
<h5 class="font-semibold">Email</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="receive_order_emails" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Receive New & Accepted order emails</span>
<p class="text-xs text-base-content/60 mt-0.5">Checking this box enables user to receive New & Accepted order emails for all customers</p>
</div>
</label>
<div class="alert bg-base-200 border-base-300">
<span class="icon-[lucide--info] size-5"></span>
<div class="text-sm">
By default, all users receive emails for customers in which they are the assigned sales rep
</div>
</div>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Data Control -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--lock] size-5"></span>
<h5 class="font-semibold">Data Control</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="limit_to_assigned_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Limit access to assigned customers</span>
<p class="text-xs text-base-content/60 mt-0.5">When enabled, this user can only view/manage customers, contacts, and orders assigned to them</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Other Settings -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--settings] size-5"></span>
<h5 class="font-semibold">Other Settings</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_developer_options" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access Developer Options</span>
<p class="text-xs text-base-content/60 mt-0.5">Create and manage Webhooks and API Keys</p>
</div>
</label>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Danger Zone -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base text-error">Danger Zone</h4>
<button type="button" class="btn btn-outline btn-error gap-2">
<span class="icon-[lucide--user-minus] size-4"></span>
Deactivate User
</button>
</div>
</div>
<div class="flex-shrink-0 border-t border-base-300 p-6 pt-4">
<div class="flex gap-3 justify-end">
<button type="button" onclick="edit_user_modal_{{ $user->id }}.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Changes
</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function openEditModal{{ $user->id }}() {
document.getElementById('edit_user_modal_{{ $user->id }}').showModal();
}
</script>
@endforeach
@endsection

View File

@@ -80,6 +80,14 @@ Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
Route::get('/brands/{brand}', [\App\Http\Controllers\MarketplaceController::class, 'showBrand'])->name('brands.show');
Route::get('/brands/{brand}/{product}', [\App\Http\Controllers\MarketplaceController::class, 'showProduct'])->name('brands.products.show');
// Brand menu browsing - buyers browse ANY seller's brand menu (cross-tenant by design)
// Note: {business} refers to SELLER's business here, not buyer's business
// Route model binding will be handled in controller to avoid auth conflicts
// URL format: /b/{businessSlug}/brands/{brandHashid}/browse (e.g., /b/cannabrands/brands/52kn5/browse)
Route::get('/{businessSlug}/brands/{brandHashid}/browse', [\App\Http\Controllers\Buyer\BrandBrowseController::class, 'browse'])->name('brands.browse')
->where('businessSlug', '[a-z0-9-]+')
->where('brandHashid', '[0-9]{2}[a-z]{2}[0-9]{1}');
// Buyer Notification Routes
Route::get('/notifications', [\App\Http\Controllers\Buyer\NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/dropdown', [\App\Http\Controllers\Buyer\NotificationController::class, 'dropdown'])->name('notifications.dropdown');

View File

@@ -35,6 +35,24 @@ Route::bind('invoice', function (string $value) {
->firstOrFail();
});
// Custom route model binding for products (business-scoped via brand)
// This ensures sellers can only access products from their own business
Route::bind('product', function (string $value) {
// Get the business from the route (will be resolved by business binding first)
$business = request()->route('business');
if (! $business) {
abort(404, 'Business not found');
}
// Find product by ID that belongs to this business via brand
$product = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($value);
return $product;
});
// Seller-specific routes under /s/ prefix (moved from /b/)
Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// Root redirect to dashboard
@@ -122,6 +140,7 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::resource('contacts', \App\Http\Controllers\Business\ContactController::class);
Route::patch('/contacts/{contact}/restore', [\App\Http\Controllers\Business\ContactController::class, 'restore'])->name('contacts.restore');
Route::get('/users', [\App\Http\Controllers\Business\UserController::class, 'index'])->name('users.index');
Route::post('/users/{user}/permissions', [\App\Http\Controllers\Business\UserController::class, 'updatePermissions'])->name('users.permissions');
// Order Management
Route::prefix('orders')->name('orders.')->group(function () {
@@ -169,7 +188,25 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
});
// Analytics and reporting
Route::get('/analytics', [\App\Http\Controllers\AnalyticsController::class, 'index'])->name('analytics.index');
Route::prefix('analytics')->name('analytics.')->group(function () {
// Main analytics dashboard (overview)
Route::get('/', [\App\Http\Controllers\Analytics\AnalyticsDashboardController::class, 'index'])->name('index');
// Product analytics
Route::get('/products', [\App\Http\Controllers\Analytics\ProductAnalyticsController::class, 'index'])->name('products');
Route::get('/products/{product}', [\App\Http\Controllers\Analytics\ProductAnalyticsController::class, 'show'])->name('products.show');
// Marketing analytics
Route::get('/marketing', [\App\Http\Controllers\Analytics\MarketingAnalyticsController::class, 'index'])->name('marketing');
Route::get('/marketing/campaigns/{campaign}', [\App\Http\Controllers\Analytics\MarketingAnalyticsController::class, 'campaign'])->name('marketing.campaign');
// Sales analytics
Route::get('/sales', [\App\Http\Controllers\Analytics\SalesAnalyticsController::class, 'index'])->name('sales');
// Buyer intelligence
Route::get('/buyers', [\App\Http\Controllers\Analytics\BuyerIntelligenceController::class, 'index'])->name('buyers');
Route::get('/buyers/{buyer}', [\App\Http\Controllers\Analytics\BuyerIntelligenceController::class, 'show'])->name('buyers.show');
});
// Document management
Route::prefix('documents')->name('documents.')->group(function () {
@@ -223,19 +260,48 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::delete('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'destroy'])->name('destroy');
});
// Brand Management (business-scoped)
Route::prefix('brands')->name('brands.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\BrandController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\BrandController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\BrandController::class, 'store'])->name('store');
Route::get('/{brand}/edit', [\App\Http\Controllers\Seller\BrandController::class, 'edit'])->name('edit');
Route::put('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'update'])->name('update');
Route::delete('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'destroy'])->name('destroy');
// Brand Preview - allows sellers to preview their brand menu as buyers see it
Route::get('/{brand}/browse/preview', [\App\Http\Controllers\Seller\BrandPreviewController::class, 'preview'])->name('preview');
});
// Settings Management (business-scoped)
Route::prefix('settings')->name('settings.')->group(function () {
Route::get('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'companyInformation'])->name('company-information');
Route::put('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'updateCompanyInformation'])->name('company-information.update');
Route::get('/users', [\App\Http\Controllers\Seller\SettingsController::class, 'users'])->name('users');
Route::post('/users/invite', [\App\Http\Controllers\Seller\SettingsController::class, 'inviteUser'])->name('users.invite');
Route::patch('/users/{user}', [\App\Http\Controllers\Seller\SettingsController::class, 'updateUser'])->name('users.update');
Route::delete('/users/{user}', [\App\Http\Controllers\Seller\SettingsController::class, 'removeUser'])->name('users.remove');
Route::get('/orders', [\App\Http\Controllers\Seller\SettingsController::class, 'orders'])->name('orders');
Route::put('/orders', [\App\Http\Controllers\Seller\SettingsController::class, 'updateOrders'])->name('orders.update');
Route::get('/brands', [\App\Http\Controllers\Seller\SettingsController::class, 'brands'])->name('brands');
Route::get('/payments', [\App\Http\Controllers\Seller\SettingsController::class, 'payments'])->name('payments');
Route::get('/invoices', [\App\Http\Controllers\Seller\SettingsController::class, 'invoices'])->name('invoices');
Route::put('/invoices', [\App\Http\Controllers\Seller\SettingsController::class, 'updateInvoices'])->name('invoices.update');
Route::get('/manage-licenses', [\App\Http\Controllers\Seller\SettingsController::class, 'manageLicenses'])->name('manage-licenses');
Route::get('/plans-and-billing', [\App\Http\Controllers\Seller\SettingsController::class, 'plansAndBilling'])->name('plans-and-billing');
Route::get('/notifications', [\App\Http\Controllers\Seller\SettingsController::class, 'notifications'])->name('notifications');
Route::put('/notifications', [\App\Http\Controllers\Seller\SettingsController::class, 'updateNotifications'])->name('notifications.update');
Route::get('/reports', [\App\Http\Controllers\Seller\SettingsController::class, 'reports'])->name('reports');
// Category Management (under settings)
Route::prefix('categories')->name('categories.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\CategoryController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Seller\CategoryController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\CategoryController::class, 'store'])->name('store');
Route::get('/{category}/edit', [\App\Http\Controllers\Seller\CategoryController::class, 'edit'])->name('edit');
Route::put('/{category}', [\App\Http\Controllers\Seller\CategoryController::class, 'update'])->name('update');
Route::delete('/{category}', [\App\Http\Controllers\Seller\CategoryController::class, 'destroy'])->name('destroy');
});
});
});
});

View File

@@ -100,9 +100,7 @@ Route::middleware('auth')->group(function () {
});
// Marketplace routes (browsing other businesses - LeafLink style)
// TODO: Create MarketplaceController and implement these routes
// Route::get('/brands/{business:slug}', [App\Http\Controllers\MarketplaceController::class, 'showBrand'])->name('brands.show');
// Route::get('/retailers/{business:slug}', [App\Http\Controllers\MarketplaceController::class, 'showRetailer'])->name('retailers.show');
// Brand browse/preview routes - MOVED TO buyer.php and seller.php for proper segregation
Route::get('/version', function () {
$path = base_path('version.txt');
@@ -197,4 +195,13 @@ Route::prefix('api')->group(function () {
Route::post('/check-email-availability', [\App\Http\Controllers\Api\EmailCheckController::class, 'checkAvailability'])
->name('api.check-email')
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
// Analytics tracking endpoints (authenticated users only)
Route::post('/analytics/session', [\App\Http\Controllers\Analytics\TrackingController::class, 'session'])
->name('analytics.session')
->middleware('throttle:60,1'); // 60 requests per minute
Route::post('/analytics/track', [\App\Http\Controllers\Analytics\TrackingController::class, 'track'])
->name('analytics.track')
->middleware('throttle:120,1'); // 120 requests per minute
});

View File

@@ -0,0 +1,220 @@
<?php
namespace Tests\Feature\Analytics;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\ProductView;
use App\Models\Business;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AnalyticsSecurityTest extends TestCase
{
use RefreshDatabase;
protected User $sellerUser1;
protected User $sellerUser2;
protected Business $business1;
protected Business $business2;
protected function setUp(): void
{
parent::setUp();
// Create two separate businesses and users
$this->business1 = Business::factory()->create(['name' => 'Business 1']);
$this->business2 = Business::factory()->create(['name' => 'Business 2']);
$this->sellerUser1 = User::factory()->create(['user_type' => 'seller']);
$this->sellerUser2 = User::factory()->create(['user_type' => 'seller']);
// Attach users to their businesses
$this->sellerUser1->businesses()->attach($this->business1->id, [
'is_primary' => true,
'permissions' => ['analytics.overview', 'analytics.products'],
]);
$this->sellerUser2->businesses()->attach($this->business2->id, [
'is_primary' => true,
'permissions' => ['analytics.overview'],
]);
}
/** @test */
public function analytics_events_are_scoped_to_business()
{
// Create events for both businesses
$event1 = AnalyticsEvent::create([
'business_id' => $this->business1->id,
'event_type' => 'product_view',
'event_category' => 'product',
'event_action' => 'view',
]);
$event2 = AnalyticsEvent::create([
'business_id' => $this->business2->id,
'event_type' => 'product_view',
'event_category' => 'product',
'event_action' => 'view',
]);
// Login as business 1 user
$this->actingAs($this->sellerUser1);
// Should only see business 1 events
$events = AnalyticsEvent::all();
$this->assertCount(1, $events);
$this->assertEquals($this->business1->id, $events->first()->business_id);
// Login as business 2 user
$this->actingAs($this->sellerUser2);
// Should only see business 2 events
$events = AnalyticsEvent::all();
$this->assertCount(1, $events);
$this->assertEquals($this->business2->id, $events->first()->business_id);
}
/** @test */
public function product_views_are_scoped_to_business()
{
// Create product views for both businesses
ProductView::create([
'business_id' => $this->business1->id,
'product_id' => 1,
'viewed_at' => now(),
]);
ProductView::create([
'business_id' => $this->business2->id,
'product_id' => 2,
'viewed_at' => now(),
]);
// Login as business 1 user
$this->actingAs($this->sellerUser1);
$this->assertCount(1, ProductView::all());
// Login as business 2 user
$this->actingAs($this->sellerUser2);
$this->assertCount(1, ProductView::all());
}
/** @test */
public function buyer_engagement_scores_are_scoped_to_business()
{
// Create scores for both businesses
BuyerEngagementScore::create([
'business_id' => $this->business1->id,
'buyer_business_id' => 100,
'total_score' => 85,
]);
BuyerEngagementScore::create([
'business_id' => $this->business2->id,
'buyer_business_id' => 200,
'total_score' => 75,
]);
// Login as business 1 user
$this->actingAs($this->sellerUser1);
$scores = BuyerEngagementScore::all();
$this->assertCount(1, $scores);
$this->assertEquals(100, $scores->first()->buyer_business_id);
// Login as business 2 user
$this->actingAs($this->sellerUser2);
$scores = BuyerEngagementScore::all();
$this->assertCount(1, $scores);
$this->assertEquals(200, $scores->first()->buyer_business_id);
}
/** @test */
public function users_cannot_access_analytics_without_permission()
{
// User 2 doesn't have analytics.products permission
$this->actingAs($this->sellerUser2);
$response = $this->get(route('seller.business.analytics.products', $this->business2->slug));
$response->assertStatus(403);
}
/** @test */
public function users_can_access_analytics_with_permission()
{
// User 1 has analytics.products permission
$this->actingAs($this->sellerUser1);
$response = $this->get(route('seller.business.analytics.products', $this->business1->slug));
$response->assertStatus(200);
}
/** @test */
public function users_cannot_access_other_business_analytics()
{
$this->actingAs($this->sellerUser1);
// Try to access business 2's analytics
$response = $this->get(route('seller.business.analytics.index', $this->business2->slug));
$response->assertStatus(403);
}
/** @test */
public function for_business_scope_removes_global_scope()
{
// Create events for both businesses
AnalyticsEvent::create([
'business_id' => $this->business1->id,
'event_type' => 'test1',
'event_category' => 'test',
'event_action' => 'test',
]);
AnalyticsEvent::create([
'business_id' => $this->business2->id,
'event_type' => 'test2',
'event_category' => 'test',
'event_action' => 'test',
]);
// Login as business 1 user
$this->actingAs($this->sellerUser1);
// Using forBusiness scope should allow access to specific business data
$events = AnalyticsEvent::forBusiness($this->business1->id)->get();
$this->assertCount(1, $events);
$this->assertEquals($this->business1->id, $events->first()->business_id);
}
/** @test */
public function unauthenticated_users_cannot_access_analytics()
{
$response = $this->get(route('seller.business.analytics.index', $this->business1->slug));
$response->assertRedirect(route('login'));
}
/** @test */
public function analytics_models_auto_set_business_id_on_creation()
{
$this->actingAs($this->sellerUser1);
// Create event without specifying business_id
$event = AnalyticsEvent::create([
'event_type' => 'test',
'event_category' => 'test',
'event_action' => 'test',
]);
// Should auto-set to current user's business
$this->assertEquals($this->business1->id, $event->business_id);
}
}