Compare commits
24 Commits
fix/ci-git
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44793c23c4 | ||
|
|
4cf62d92e4 | ||
|
|
7c0ec86823 | ||
|
|
2c3d12a22c | ||
|
|
1371d2a59c | ||
|
|
4dd2e3ae64 | ||
|
|
1649909b73 | ||
|
|
a5ac7d4217 | ||
|
|
eb05a6bcf0 | ||
|
|
572c207e39 | ||
|
|
ef5f430e90 | ||
|
|
a99a0807d0 | ||
|
|
a0194bad9b | ||
|
|
91451893fe | ||
|
|
1786c2edb1 | ||
|
|
9a81a22cc5 | ||
|
|
19bfa889b7 | ||
|
|
c4bd508241 | ||
|
|
b404a533b3 | ||
|
|
65380b9649 | ||
|
|
52facb768e | ||
|
|
e9230495b4 | ||
|
|
06869cf05d | ||
|
|
555b988c4f |
22
.env.example
22
.env.example
@@ -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
7
.gitignore
vendored
@@ -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
337
ANALYTICS_IMPLEMENTATION.md
Normal 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
196
ANALYTICS_QUICK_START.md
Normal 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!
|
||||
216
ANALYTICS_TRACKING_EXAMPLES.md
Normal file
216
ANALYTICS_TRACKING_EXAMPLES.md
Normal 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.
|
||||
66
CLAUDE.md
66
CLAUDE.md
@@ -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
|
||||
|
||||
56
app/Events/HighIntentBuyerDetected.php
Normal file
56
app/Events/HighIntentBuyerDetected.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
103
app/Helpers/BusinessHelper.php
Normal file
103
app/Helpers/BusinessHelper.php
Normal 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
24
app/Helpers/helpers.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
192
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
192
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal file
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
148
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal file
148
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/Analytics/TrackingController.php
Normal file
190
app/Http/Controllers/Analytics/TrackingController.php
Normal 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', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
230
app/Http/Controllers/Seller/BrandController.php
Normal file
230
app/Http/Controllers/Seller/BrandController.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal file
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
249
app/Jobs/CalculateEngagementScore.php
Normal file
249
app/Jobs/CalculateEngagementScore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
app/Jobs/ProcessAnalyticsEvent.php
Normal file
61
app/Jobs/ProcessAnalyticsEvent.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
105
app/Models/Analytics/AnalyticsEvent.php
Normal file
105
app/Models/Analytics/AnalyticsEvent.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
138
app/Models/Analytics/BuyerEngagementScore.php
Normal file
138
app/Models/Analytics/BuyerEngagementScore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
79
app/Models/Analytics/ClickTracking.php
Normal file
79
app/Models/Analytics/ClickTracking.php
Normal 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);
|
||||
}
|
||||
}
|
||||
86
app/Models/Analytics/EmailCampaign.php
Normal file
86
app/Models/Analytics/EmailCampaign.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
app/Models/Analytics/EmailClick.php
Normal file
64
app/Models/Analytics/EmailClick.php
Normal 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);
|
||||
}
|
||||
}
|
||||
162
app/Models/Analytics/EmailInteraction.php
Normal file
162
app/Models/Analytics/EmailInteraction.php
Normal 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);
|
||||
}
|
||||
}
|
||||
106
app/Models/Analytics/IntentSignal.php
Normal file
106
app/Models/Analytics/IntentSignal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
92
app/Models/Analytics/ProductView.php
Normal file
92
app/Models/Analytics/ProductView.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
102
app/Models/Analytics/UserSession.php
Normal file
102
app/Models/Analytics/UserSession.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
340
app/Services/AnalyticsTracker.php
Normal file
340
app/Services/AnalyticsTracker.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
58
app/Traits/HasHashid.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"files": [
|
||||
"app/helpers.php"
|
||||
"app/helpers.php",
|
||||
"app/Helpers/helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
65
database/seeders/CannabrandsHashFactorySeeder.php
Normal file
65
database/seeders/CannabrandsHashFactorySeeder.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
233
public/js/analytics-tracker.js
Normal file
233
public/js/analytics-tracker.js
Normal 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 };
|
||||
}
|
||||
146
public/js/reverb-analytics-listener.js
Normal file
146
public/js/reverb-analytics-listener.js
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>© {{ date('Y') }} {{ config('version.company.name') }}.com, {{ config('version.company.suffix') }}</p>
|
||||
<p>© {{ date('Y') }} Made with <span class="text-error">♥</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">
|
||||
|
||||
@@ -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>© {{ date('Y') }} {{ config('version.company.name') }}.com, {{ config('version.company.suffix') }}</p>
|
||||
<p>© {{ date('Y') }} Made with <span class="text-error">♥</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">
|
||||
|
||||
@@ -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>
|
||||
© {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
|
||||
© {{ date('Y') }} Made with <span class="text-error">♥</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>
|
||||
|
||||
@@ -406,6 +406,9 @@
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Analytics Tracking -->
|
||||
@include('partials.analytics')
|
||||
|
||||
<!-- Page-specific scripts -->
|
||||
@stack('scripts')
|
||||
</body>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
<footer class="text-center text-sm text-base-content/secondary py-4 bg-base-100">
|
||||
<p>
|
||||
© {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
|
||||
© {{ date('Y') }} Made with <span class="text-error">♥</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>
|
||||
|
||||
18
resources/views/partials/analytics-tracking.blade.php
Normal file
18
resources/views/partials/analytics-tracking.blade.php
Normal 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
|
||||
238
resources/views/partials/analytics.blade.php
Normal file
238
resources/views/partials/analytics.blade.php
Normal 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>
|
||||
24
resources/views/seller/analytics/buyer-detail.blade.php
Normal file
24
resources/views/seller/analytics/buyer-detail.blade.php
Normal 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>
|
||||
228
resources/views/seller/analytics/buyers.blade.php
Normal file
228
resources/views/seller/analytics/buyers.blade.php
Normal 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
|
||||
24
resources/views/seller/analytics/campaign-detail.blade.php
Normal file
24
resources/views/seller/analytics/campaign-detail.blade.php
Normal 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>
|
||||
328
resources/views/seller/analytics/dashboard.blade.php
Normal file
328
resources/views/seller/analytics/dashboard.blade.php
Normal 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
|
||||
155
resources/views/seller/analytics/marketing.blade.php
Normal file
155
resources/views/seller/analytics/marketing.blade.php
Normal 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
|
||||
24
resources/views/seller/analytics/product-detail.blade.php
Normal file
24
resources/views/seller/analytics/product-detail.blade.php
Normal 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>
|
||||
131
resources/views/seller/analytics/products.blade.php
Normal file
131
resources/views/seller/analytics/products.blade.php
Normal 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
|
||||
209
resources/views/seller/analytics/sales.blade.php
Normal file
209
resources/views/seller/analytics/sales.blade.php
Normal 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
|
||||
412
resources/views/seller/brands/create.blade.php
Normal file
412
resources/views/seller/brands/create.blade.php
Normal 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
|
||||
520
resources/views/seller/brands/edit.blade.php
Normal file
520
resources/views/seller/brands/edit.blade.php
Normal 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
|
||||
545
resources/views/seller/brands/preview.blade.php
Normal file
545
resources/views/seller/brands/preview.blade.php
Normal 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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
220
tests/Feature/Analytics/AnalyticsSecurityTest.php
Normal file
220
tests/Feature/Analytics/AnalyticsSecurityTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user