Compare commits

...

36 Commits

Author SHA1 Message Date
kelly
fd11ae0fe0 feat: add mobile responsiveness to tables and sidebar
- Fix sidebar collapse CSS
- Remove sidebar checkbox name attributes
- Add responsive hidden columns for:
  - accounts index
  - automations index
  - leads index
  - invoices create
  - marketing contacts index
  - products index
2025-12-11 10:07:52 -07:00
kelly
16c5c455fa fix: make CRM Accounts page responsive
- Header stacks vertically on mobile
- Hide Primary Contact and Status columns on xs screens
- Hide Orders column on sm screens
- Hide Open Opps column on md screens
2025-12-11 10:05:54 -07:00
kelly
df587fdda3 fix: make Orders and Invoices pages responsive
- Orders: hide Date, Items, Fulfillment columns on mobile
- Invoices: hide Customer, Invoice Date, Due Date on mobile
- Headers stack vertically on mobile with proper spacing
- Essential columns visible on all screen sizes
2025-12-11 10:04:27 -07:00
kelly
33c9420b00 Merge pull request 'fix: UI standardization and sidebar improvements' (#193) from fix/ui-standardization-and-sidebar into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/193
2025-12-11 15:09:58 +00:00
kelly
37204edfd7 fix: UI standardization and sidebar improvements
- Fix sidebar menu items requiring double-click (checkbox overlay issue)
- Remove jittery scroll animation on sidebar navigation
- Remove green search button, use neutral icon instead
- Standardize dropdown menus across all pages (btn instead of label, menu-sm)
- Fix brand dashboard topPerformer undefined key error
- Fix product hashid validation for image routes
- Initialize Alpine.js search state from URL params on products page
- Update theme colors in tailwind config
2025-12-11 01:37:41 -07:00
kelly
8d9725b501 fix: replace inline SVGs with HTML entities in CannaiQ settings
SVG icons were rendering at full page size due to missing size
constraints. Replaced with HTML character entities instead.
2025-12-11 00:09:25 -07:00
kelly
6cf8ad1854 feat(admin): add CannaiQ settings page under Integrations
- Add CannaiQ page to sidebar navigation under Integrations group
- Shows connection status (API key configured or trusted origin)
- Displays available features (Brand Analysis, Intelligence, Promos)
- Shows environment variable configuration
- Includes Test Connection and Clear Cache buttons
- Documents how to enable CannaiQ per-business
2025-12-11 00:07:06 -07:00
kelly
58f787feb0 feat(admin): add Integrations tab with CannaiQ section
- Move CannaiQ settings from Suites tab to new Integrations tab
- Add feature list placeholder explaining CannaiQ capabilities
- Add link to CannaiQ website for more information
2025-12-11 00:02:38 -07:00
kelly
970ce05846 Merge pull request 'feat: Brand Analysis page + Crystal bug fixes (#176, #180, #182)' (#186) from fix/brand-analysis-404 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/186
2025-12-11 05:33:54 +00:00
kelly
672b0d5f6b chore: re-trigger CI 2025-12-10 22:21:11 -07:00
kelly
4415194b28 fix: require CannaiQ enabled for Brand Analysis, show connection errors
- Block access to Brand Analysis page when CannaiQ is disabled
  - Show analysis-disabled.blade.php with feature info and contact support CTA
  - Add checks to analysis() and analysisRefresh() controller methods

- Add connectionError property to BrandAnalysisDTO
  - When CannaiQ is enabled but API fails, show error instead of silent fallback
  - cannaiqEnabled stays true (feature IS enabled, just API unavailable)

- Update analysis.blade.php to display connection errors
  - Red 'Connection Error' badge in header when API fails
  - Alert banner with error message and 'Contact Support' link
  - Users can see the issue clearly and know to contact support
2025-12-10 22:21:11 -07:00
kelly
213b0ef8f2 feat: add cannaiq_enabled check to brand analysis endpoints 2025-12-10 22:21:11 -07:00
kelly
13dbe046e1 fix: add missing CannaiQ brand analytics API methods
BrandAnalysisService calls getBrandMetrics(), getBrandCompetitors(),
getBrandPromoMetrics(), and getBrandSlippage() methods that were not
defined in CannaiqClient. These v1.5 brand analytics endpoints enable:

- Whitespace and regional penetration data
- Competitor head-to-head comparisons
- Promotion velocity lift metrics
- Slippage/churn detection

All methods return graceful error responses if the API endpoints
don't exist yet, allowing the service to fall back to basic analysis.
2025-12-10 22:21:11 -07:00
kelly
592df4de44 fix: resolve Crystal issues #176, #180, #182
Issue #176: Products - Pricing Not Listed
- Cast wholesale_price to float when building product listings JSON
- PostgreSQL numeric columns return strings, breaking JS `.toFixed(2)`
- Fixed in ProductController index() and listings() methods

Issue #180: Quotes - New Customer Not in Dropdown
- Removed `whereHas('contacts')` filter from account query
- Newly created customers without contacts were being excluded
- Added `where('status', 'approved')` filter instead

Issue #182: Invoices - Search Not Working
- Added search/status parameter handling to index() method
- Search filters by invoice number or customer business name
- Status filters by unpaid/paid/overdue
- Added withQueryString() to pagination for filter persistence
2025-12-10 22:21:11 -07:00
kelly
ae581b4d5c Merge pull request 'fix: Crystal issues batch 2 - products, batches, images' (#192) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/192
2025-12-11 05:10:15 +00:00
kelly
8a8f83cc0c feat: backfill product/brand descriptions from MySQL
- Remove min/max validation from tagline, description, long_description
- Add migration to import long_description from product_extras
- Restore 8 soft-deleted Nuvata products
- Update 13 brands with tagline/description/long_description from MySQL
2025-12-10 21:38:06 -07:00
kelly
722904d487 fix: Crystal issues batch 2 - products, batches, images
- Fix #190: Product image upload now uses MinIO (default disk) with proper
  path structure: businesses/{slug}/brands/{slug}/products/{sku}/images/

- Fix #176: Products index now uses effective_price accessor instead of
  just wholesale_price, so sale prices display correctly

- Fix #163: Batch create page was referencing non-existent 'component'
  relationship - changed to 'product' which is the actual relationship
2025-12-10 21:37:24 -07:00
kelly
ddc84f6730 Merge pull request 'fix(#187): Customer Edit/Delete Contact Errors' (#191) from fix/crystal-customer-edits-187 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/191
2025-12-11 02:55:03 +00:00
kelly
2c510844f0 Merge pull request 'feat: Brand Analysis v4 + fix #172 brand dashboard products tab' (#181) from fix/brand-analysis-v4-fixes into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/181
2025-12-11 02:03:53 +00:00
kelly
105a1e8ce0 Merge pull request 'fix(#182): add search and status filtering to invoice index' (#189) from fix/invoice-search-182 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/189
2025-12-11 02:02:53 +00:00
kelly
7e06ff3488 fix(#187): use contact hashid for route parameters instead of id
Contact model uses HasHashid trait which sets getRouteKeyName() to 'hashid'.
Routes expecting {contact} parameter require hashid, not numeric id.

Fixed in:
- contacts-edit.blade.php: update and destroy form actions
- contacts/index.blade.php: destroy form action in archive dropdown
2025-12-10 18:57:38 -07:00
kelly
aed1e62c65 style: fix Pint issues in brand analysis files
- single_quote fixes
- unary_operator_spaces fixes
- concat_space fixes
2025-12-10 18:36:36 -07:00
kelly
f9f1b8dc46 Merge pull request 'fix: resolve Crystal issues #161 and #180 (Quotes customer dropdown)' (#188) from fix/crystal-issues-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/188
2025-12-11 01:34:22 +00:00
kelly
89d3a54988 fix(#182): add search and status filtering to invoice index
- Added search by invoice number or customer business name (case-insensitive)
- Added status filter (unpaid/paid/overdue)
- Added withQueryString() to preserve filters during pagination
2025-12-10 18:33:58 -07:00
kelly
0c60e5c519 fix(#161): show all approved buyers in quotes customer dropdown
The quote create form was filtering accounts by whereHas('contacts'),
which excluded newly created buyer businesses that don't have contacts
yet. Changed to filter by status='approved' instead, allowing contacts
to be added after selecting the account.

This also fixes #180 (new customer not in quotes dropdown).
2025-12-10 18:19:29 -07:00
kelly
1ecc4a916b Merge pull request 'fix: case-insensitive product search and hashid defensive checks' (#179) from fix/product-search-case-insensitive into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/179
2025-12-11 00:49:35 +00:00
kelly
d4ec8c16f3 chore: re-trigger CI 2025-12-10 17:29:05 -07:00
kelly
f9d7573cb4 chore: re-trigger CI 2025-12-10 17:28:17 -07:00
kelly
e48e9c9b82 Merge pull request 'feat: add build date/time to sidebar version display' (#184) from feat/sidebar-build-date into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/184
2025-12-11 00:19:56 +00:00
kelly
afbb1ba79c Merge pull request 'fix(ci): use explicit git clone plugin for auth' (#185) from fix/ci-git-auth into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/185
2025-12-11 00:16:59 +00:00
kelly
08f5a3adac feat: add build date/time to sidebar version display
- Add appBuildDate variable from AppServiceProvider
- In local dev: shows commit date (e.g., 'Dec 10, 2:30pm')
- In production: reads BUILD_DATE from version.env
- Updated all sidebars: seller-suites, buyer, seller-legacy, brand-portal
- Updated Filament admin footer
2025-12-10 16:49:56 -07:00
kelly
e62ea5c809 fix: correct Blade syntax error in Promo Performance section
- Wrap promo table in @if(!empty($promosList)) check
- Add proper @else block for empty state message
- Fixes ParseError: unexpected token endif at line 1254
2025-12-10 16:17:21 -07:00
kelly
8d43953cad fix(#172): add defensive hashid filtering for brand products tab
Products without hashids would cause route generation errors.
Added whereNotNull('hashid') to query and filter() to collection.
2025-12-10 16:16:29 -07:00
kelly
a628f2b207 feat: add v3 comparables, supporting signals, and improve slippage display 2025-12-10 16:07:33 -07:00
kelly
367daadfe9 feat: add Brand Analysis page with CannaiQ intelligence
- Add BrandAnalysisService for market intelligence data
- Add BrandAnalysisDTO for structured analysis data
- Add AdvancedV3IntelligenceService for advanced metrics
- Add analysis(), analysisRefresh(), storePlaybook() to BrandController
- Add brand analysis routes
- Add analysis.blade.php view with:
  - Retail partner placement metrics
  - Competitor landscape analysis
  - Inventory projection alerts
  - Promo performance tracking
  - Slippage/action required alerts
  - V3 market signals and shelf opportunities
2025-12-10 15:40:38 -07:00
kelly
b33ebac9bf fix: make product search case-insensitive and add defensive hashid checks
- Use ILIKE instead of LIKE for PostgreSQL case-insensitive search
- Add hashid fallback in Brand::getLogoUrl() and getBannerUrl()
- Prevents route generation errors when hashid is missing from eager load
- Reduce eager loading in index() to only needed columns
- Add pagination to listings() method
- Filter hashid at DB level instead of PHP collection
2025-12-08 17:06:25 -07:00
64 changed files with 8100 additions and 324 deletions

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Filament\Pages;
use App\Services\Cannaiq\CannaiqClient;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
class CannaiqSettings extends Page implements HasForms
{
use InteractsWithForms;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
protected string $view = 'filament.pages.cannaiq-settings';
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
protected static ?string $navigationLabel = 'CannaiQ';
protected static ?int $navigationSort = 1;
protected static ?string $title = 'CannaiQ Settings';
protected static ?string $slug = 'cannaiq-settings';
public ?array $data = [];
public static function canAccess(): bool
{
return auth('admin')->check();
}
public function mount(): void
{
$this->form->fill([
'base_url' => config('services.cannaiq.base_url'),
'api_key' => '', // Never show the actual key
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
]);
}
public function form(Schema $schema): Schema
{
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
$baseUrl = config('services.cannaiq.base_url');
return $schema
->schema([
Section::make('CannaiQ Integration')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Placeholder::make('status')
->label('Connection Status')
->content(function () use ($apiKeyConfigured, $baseUrl) {
$statusHtml = '<div class="space-y-2">';
// API Key status
if ($apiKeyConfigured) {
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
'<span class="text-lg">&#10003;</span>'.
'<span>API Key configured</span>'.
'</div>';
} else {
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
'<span class="text-lg">&#9888;</span>'.
'<span>API Key not configured (using trusted origin auth)</span>'.
'</div>';
}
// Base URL
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
'</div>';
$statusHtml .= '</div>';
return new HtmlString($statusHtml);
}),
Placeholder::make('features')
->label('Features Enabled')
->content(new HtmlString(
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
'</ul>'.
'</div>'
)),
]),
Section::make('Configuration')
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
->schema([
TextInput::make('base_url')
->label('Base URL')
->disabled()
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
TextInput::make('cache_ttl')
->label('Cache TTL (seconds)')
->disabled()
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
Placeholder::make('env_example')
->label('Environment Variables')
->content(new HtmlString(
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
'</div>'
)),
])
->collapsed(),
Section::make('Business Access')
->description('CannaiQ features must be enabled per-business in the Business settings.')
->schema([
Placeholder::make('business_info')
->label('')
->content(new HtmlString(
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
'<div class="flex items-start gap-3">'.
'<span class="text-info-600 dark:text-info-400 text-lg">&#9432;</span>'.
'<div class="text-sm">'.
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
'<li>Go to <strong>Users &rarr; Businesses</strong></li>'.
'<li>Edit the business</li>'.
'<li>Go to the <strong>Integrations</strong> tab</li>'.
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
'</ol>'.
'</div>'.
'</div>'.
'</div>'
)),
]),
])
->statePath('data');
}
public function testConnection(): void
{
try {
$client = app(CannaiqClient::class);
// Try to fetch something from the API to verify connection
// We'll use a simple health check or fetch minimal data
$response = $client->getBrandAnalysis('test-brand', 'test-business');
// If we get here without exception, connection works
// (even if the response is empty/error from CannaiQ side)
Notification::make()
->title('Connection Test')
->body('Successfully connected to CannaiQ API')
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Connection Failed')
->body($e->getMessage())
->danger()
->send();
}
}
public function clearCache(): void
{
// Clear all CannaiQ-related cache keys
$patterns = [
'cannaiq:*',
'brand_analysis:*',
];
$cleared = 0;
foreach ($patterns as $pattern) {
// Note: This is a simplified clear - in production you might want
// to use Redis SCAN for pattern matching
Cache::forget($pattern);
$cleared++;
}
Notification::make()
->title('Cache Cleared')
->body('CannaiQ cache has been cleared')
->success()
->send();
}
}

View File

@@ -701,17 +701,6 @@ class BusinessResource extends Resource
}),
]),
// ===== CANNAIQ SECTION =====
// CannaiQ Marketing Intelligence Engine
Section::make('CannaiQ')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Toggle::make('cannaiq_enabled')
->label('Enable CannaiQ')
->helperText('When enabled, this business gets access to Intelligence and Promos features under the Growth menu.')
->default(false),
]),
// ===== SUITE ASSIGNMENT SECTION =====
// Suites control feature access (menus, screens, capabilities)
Section::make('Suite Assignment')
@@ -863,6 +852,40 @@ class BusinessResource extends Resource
]),
]),
// ===== INTEGRATIONS TAB =====
// Third-party service integrations
Tab::make('Integrations')
->icon('heroicon-o-link')
->schema([
// ===== CANNAIQ SECTION =====
Section::make('CannaiQ')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Toggle::make('cannaiq_enabled')
->label('Enable CannaiQ')
->helperText('When enabled, this business gets access to Brand Analysis, Intelligence, and Promos features.')
->default(false),
Forms\Components\Placeholder::make('cannaiq_info')
->label('')
->content(new \Illuminate\Support\HtmlString(
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4 text-sm">'.
'<div class="font-medium text-gray-700 dark:text-gray-300 mb-2">CannaiQ Features</div>'.
'<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">'.
'<li>Brand Analysis - Market positioning, SKU velocity, shelf opportunities</li>'.
'<li>Marketing Intelligence - Competitive insights and recommendations</li>'.
'<li>Promo Recommendations - AI-powered promotional strategies</li>'.
'</ul>'.
'<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">'.
'<a href="https://cannaiq.co" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 font-medium inline-flex items-center gap-1">'.
'Visit CannaiQ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>'.
'</a>'.
'</div>'.
'</div>'
)),
]),
]),
// ===== LEGACY MODULES TAB =====
// These flags are kept for backward compatibility.
// The recommended way to configure access is via Suites above.

View File

@@ -80,7 +80,7 @@ class BatchController extends Controller
->where('quantity_available', '>', 0)
->where('is_active', true)
->where('is_quarantined', false)
->with('component')
->with('product')
->orderBy('batch_number')
->get()
->map(function ($batch) {

View File

@@ -333,11 +333,14 @@ class BrandController extends Controller
{
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->whereNotNull('hashid')
->where('hashid', '!=', '')
->with('images')
->orderBy('created_at', 'desc')
->paginate($perPage);
$products = $productsPaginator->getCollection()
->filter(fn ($product) => ! empty($product->hashid))
->map(function ($product) use ($business, $brand) {
$product->setRelation('brand', $brand);
@@ -354,7 +357,8 @@ class BrandController extends Controller
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
];
});
})
->values();
return [
'products' => $products,
@@ -1948,4 +1952,141 @@ class BrandController extends Controller
'visibilityIssues' => $visibilityIssues,
];
}
/**
* Display brand market analysis / intelligence page.
*
* v4 endpoint with optional store_id filtering for per-store projections.
*/
public function analysis(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// CannaiQ must be enabled to access Brand Analysis
if (! $business->cannaiq_enabled) {
return view('seller.brands.analysis-disabled', [
'business' => $business,
'brand' => $brand,
]);
}
// v4: Get optional store_id filter for shelf value projections
$storeId = $request->query('store_id');
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$analysis = $analysisService->getAnalysis($brand, $business, $storeId);
// Load all brands for the brand selector
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// Build store list from placement data for store selector
$storeList = [];
if ((bool) $business->cannaiq_enabled) {
$placementStores = $analysis->placement['stores'] ?? $analysis->placement ?? [];
$whitespaceStores = $analysis->placement['whitespaceStores'] ?? [];
foreach ($placementStores as $store) {
$storeList[] = [
'id' => $store['storeId'] ?? '',
'name' => $store['storeName'] ?? 'Unknown',
'state' => $store['state'] ?? null,
];
}
foreach ($whitespaceStores as $store) {
$storeList[] = [
'id' => $store['storeId'] ?? '',
'name' => $store['storeName'] ?? 'Unknown',
'state' => $store['state'] ?? null,
];
}
}
return view('seller.brands.analysis', [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'analysis' => $analysis,
'cannaiqEnabled' => (bool) $business->cannaiq_enabled,
'storeList' => $storeList,
'selectedStoreId' => $storeId,
]);
}
/**
* Refresh brand analysis data (clears cache and re-fetches).
*/
public function analysisRefresh(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// CannaiQ must be enabled to refresh analysis
if (! $business->cannaiq_enabled) {
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'CannaiQ is not enabled for this business. Please contact support.',
], 403);
}
return back()->with('error', 'CannaiQ is not enabled for this business.');
}
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$analysis = $analysisService->refreshAnalysis($brand, $business);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Analysis data refreshed',
'data' => $analysis->toArray(),
]);
}
return redirect()
->route('seller.business.brands.analysis', [$business->slug, $brand->hashid])
->with('success', 'Analysis data refreshed successfully');
}
/**
* Get store-level playbook for a specific store.
*
* Returns targeted recommendations for a single retail account.
*/
public function storePlaybook(Request $request, Business $business, Brand $brand, string $storeId)
{
$this->authorize('view', [$brand, $business]);
if (! $business->cannaiq_enabled) {
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'CannaiQ is not enabled for this business',
], 403);
}
return back()->with('error', 'CannaiQ is not enabled for this business');
}
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$playbook = $analysisService->getStorePlaybook($brand, $business, $storeId);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'data' => $playbook,
]);
}
// For non-JSON requests, redirect to analysis page with store selected
return redirect()
->route('seller.business.brands.analysis', [
$business->slug,
$brand->hashid,
'store_id' => $storeId,
]);
}
}

View File

@@ -52,11 +52,13 @@ class QuoteController extends Controller
*/
public function create(Request $request, Business $business)
{
// Get buyer businesses that have contacts (potential and existing customers)
// Get all approved buyer businesses as potential customers
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
// Include locations for delivery address selection
// Note: We don't filter by whereHas('contacts') because newly created customers
// may not have contacts yet - contacts can be added after selecting the account
$accounts = Business::where('type', 'buyer')
->whereHas('contacts')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])

View File

@@ -118,7 +118,7 @@ class InvoiceController extends Controller
/**
* Display a listing of invoices for the business.
*/
public function index(Business $business)
public function index(Business $business, Request $request)
{
// Get brand IDs for this business (single query, reused for filtering)
$brandIds = $business->brands()->pluck('id');
@@ -138,11 +138,34 @@ class InvoiceController extends Controller
->where('due_date', '<', now())->count(),
];
// Apply search filter - search by customer business name or invoice number
$search = $request->input('search');
if ($search) {
$baseQuery->where(function ($query) use ($search) {
$query->where('invoice_number', 'ilike', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%");
});
});
}
// Apply status filter
$status = $request->input('status');
if ($status === 'unpaid') {
$baseQuery->where('payment_status', 'unpaid');
} elseif ($status === 'paid') {
$baseQuery->where('payment_status', 'paid');
} elseif ($status === 'overdue') {
$baseQuery->where('payment_status', '!=', 'paid')
->where('due_date', '<', now());
}
// Paginate with only the relations needed for display
$invoices = (clone $baseQuery)
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
->latest()
->paginate(25);
->paginate(25)
->withQueryString();
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
}

View File

@@ -106,7 +106,7 @@ class ProductController extends Controller
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku ?? 'N/A',
'price' => $variety->wholesale_price ?? 0,
'price' => $variety->effective_price ?? $variety->wholesale_price ?? 0,
'status' => $variety->is_active ? 'active' : 'inactive',
'image_url' => $variety->getImageUrl('thumb'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
@@ -123,7 +123,7 @@ class ProductController extends Controller
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'price' => $product->effective_price ?? $product->wholesale_price ?? 0,
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
@@ -479,9 +479,14 @@ class ProductController extends Controller
$product = Product::create($validated);
// Handle image uploads if present
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
if ($request->hasFile('images')) {
$brand = $product->brand;
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
foreach ($request->file('images') as $index => $image) {
$path = $image->store('products', 'public');
$filename = $image->hashName();
$path = $image->storeAs($basePath, $filename);
$product->images()->create([
'path' => $path,
'type' => 'product',
@@ -1032,7 +1037,7 @@ class ProductController extends Controller
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'price' => (float) ($product->wholesale_price ?? 0),
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation

View File

@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
return [
'name' => 'required|string|max:255',
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
'description' => ['nullable', 'string', 'min:100', 'max:150'],
'long_description' => ['nullable', 'string', 'max:500'],
'tagline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'long_description' => ['nullable', 'string', 'max:5000'],
'brand_announcement' => ['nullable', 'string', 'max:500'],
'website_url' => 'nullable|string|max:255',

View File

@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
return [
'name' => 'required|string|max:255',
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
'description' => ['nullable', 'string', 'min:100', 'max:150'],
'long_description' => ['nullable', 'string', 'max:500'],
'tagline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'long_description' => ['nullable', 'string', 'max:5000'],
'brand_announcement' => ['nullable', 'string', 'max:500'],
'website_url' => 'nullable|string|max:255',

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Jobs;
use App\Models\Brand;
use App\Models\Business;
use App\Services\Cannaiq\BrandAnalysisService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Background job to pre-calculate Brand Analysis metrics.
*
* This job runs in the background to compute expensive engagement and sentiment
* metrics for brands, caching the results for 2 hours. This prevents N+1 queries
* and expensive aggregations from running on page load.
*
* Schedule: Every 2 hours via Horizon
* Queue: default (or 'analytics' if available)
*
* Key benefits:
* - Aggregates CRM message counts, response rates, and quote/order metrics in batch
* - Pre-computes buyer engagement scores
* - For CannaiQ-enabled businesses, also pre-computes sentiment scores
* - Uses existing BrandAnalysisService caching mechanism (2-hour TTL)
*/
class CalculateBrandAnalysisMetrics implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The business to calculate metrics for (null = all seller businesses)
*/
public ?int $businessId;
/**
* The brand to calculate metrics for (null = all brands in business)
*/
public ?int $brandId;
/**
* Create a new job instance.
*/
public function __construct(?int $businessId = null, ?int $brandId = null)
{
$this->businessId = $businessId;
$this->brandId = $brandId;
}
/**
* Execute the job.
*/
public function handle(BrandAnalysisService $service): void
{
$startTime = microtime(true);
$processedCount = 0;
try {
if ($this->businessId && $this->brandId) {
// Single brand calculation
$this->calculateForBrand($service, $this->businessId, $this->brandId);
$processedCount = 1;
} elseif ($this->businessId) {
// All brands for a single business
$processedCount = $this->calculateForBusiness($service, $this->businessId);
} else {
// All seller businesses with active brands
$processedCount = $this->calculateForAllBusinesses($service);
}
$duration = round(microtime(true) - $startTime, 2);
Log::info('CalculateBrandAnalysisMetrics completed', [
'business_id' => $this->businessId ?? 'all',
'brand_id' => $this->brandId ?? 'all',
'brands_processed' => $processedCount,
'duration_seconds' => $duration,
]);
} catch (\Exception $e) {
Log::error('CalculateBrandAnalysisMetrics failed', [
'business_id' => $this->businessId,
'brand_id' => $this->brandId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Calculate metrics for all seller businesses
*/
private function calculateForAllBusinesses(BrandAnalysisService $service): int
{
$processedCount = 0;
Business::where('type', 'seller')
->where('status', 'approved')
->chunk(10, function ($businesses) use ($service, &$processedCount) {
foreach ($businesses as $business) {
$processedCount += $this->calculateForBusiness($service, $business->id);
}
});
return $processedCount;
}
/**
* Calculate metrics for all active brands in a business
*/
private function calculateForBusiness(BrandAnalysisService $service, int $businessId): int
{
$business = Business::find($businessId);
if (! $business) {
return 0;
}
$brands = Brand::where('business_id', $businessId)
->where('is_active', true)
->get();
foreach ($brands as $brand) {
$this->calculateForBrand($service, $businessId, $brand->id);
}
return $brands->count();
}
/**
* Calculate metrics for a single brand
*/
private function calculateForBrand(BrandAnalysisService $service, int $businessId, int $brandId): void
{
$business = Business::find($businessId);
$brand = Brand::find($brandId);
if (! $business || ! $brand) {
return;
}
// This triggers the full analysis calculation and caches it
// The BrandAnalysisService handles caching internally with 2-hour TTL
$service->refreshAnalysis($brand, $business);
}
/**
* The job failed to process.
*/
public function failed(\Throwable $exception): void
{
Log::error('CalculateBrandAnalysisMetrics job failed', [
'business_id' => $this->businessId,
'brand_id' => $this->brandId,
'exception' => $exception->getMessage(),
]);
}
}

View File

@@ -109,6 +109,7 @@ class AppServiceProvider extends ServiceProvider
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
$version = 'dev';
$commit = 'unknown';
$buildDate = null;
// For Docker: read from version.env (injected at build time)
$versionFile = base_path('version.env');
@@ -117,6 +118,7 @@ class AppServiceProvider extends ServiceProvider
$data = parse_ini_file($versionFile);
$version = $data['VERSION'] ?? 'dev';
$commit = $data['COMMIT'] ?? 'unknown';
$buildDate = $data['BUILD_DATE'] ?? null;
}
// For local dev: read from git directly (but cached for 5 seconds)
// Check for .git (directory for regular repos, file for worktrees)
@@ -128,6 +130,13 @@ class AppServiceProvider extends ServiceProvider
// Only proceed if we successfully got a commit SHA
if ($commit !== '' && $commit !== 'unknown') {
// Get commit date for local dev
$dateCommand = sprintf('cd %s && git log -1 --format=%%ci 2>/dev/null', escapeshellarg(base_path()));
$commitDate = trim(shell_exec($dateCommand) ?: '');
if ($commitDate) {
$buildDate = date('M j, g:ia', strtotime($commitDate));
}
// Check for uncommitted changes (dirty working directory)
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
@@ -147,17 +156,19 @@ class AppServiceProvider extends ServiceProvider
return [
'version' => $version,
'commit' => $commit,
'buildDate' => $buildDate,
];
});
} catch (\Exception $e) {
// If cache fails (e.g., Redis not ready), calculate version without caching
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
$versionData = ['version' => 'dev', 'commit' => 'unknown', 'buildDate' => null];
$versionFile = base_path('version.env');
if (File::exists($versionFile)) {
$data = parse_ini_file($versionFile);
$versionData['version'] = $data['VERSION'] ?? 'dev';
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
$versionData['buildDate'] = $data['BUILD_DATE'] ?? null;
}
}
@@ -165,6 +176,7 @@ class AppServiceProvider extends ServiceProvider
$view->with([
'appVersion' => $versionData['version'],
'appCommit' => $versionData['commit'],
'appBuildDate' => $versionData['buildDate'],
'appVersionFull' => "{$versionData['version']} (sha-{$versionData['commit']})",
]);
});

View File

@@ -260,10 +260,10 @@ class BrandVoicePrompt
* AI generation MUST respect these limits.
*/
public const CHARACTER_LIMITS = [
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
@@ -803,6 +803,11 @@ class BrandVoicePrompt
return '';
}
// If no min is set, only enforce max
if ($limits['min'] === null) {
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
}
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
}

View File

@@ -0,0 +1,533 @@
<?php
namespace App\Services\Cannaiq;
/**
* Advanced v4 Intelligence Data Transfer Object
*
* Contains advanced brand intelligence analytics including:
* - Brand positioning and differentiation scoring (v3)
* - Trend lead/lag analysis (predictive vs laggy behavior) (v3)
* - Cross-state market signals (v3)
* - Shelf displacement opportunities (v3)
* - Shelf value projections with capture scenarios (v4)
*
* v4.0 Additions:
* - shelfValueProjections: Revenue projections by scope (store/state/multi_state)
* - capture_scenarios: 10%, 25%, 50% market capture modeling
* - opportunity_label: "Big prize, low effort" etc.
* - consumerDemand: Consumer Demand Index + SKU lifecycle stages
* - elasticity: Price elasticity metrics per SKU
* - competitiveThreat: Competitive pressure scoring
* - portfolioBalance: Category mix, redundancy clusters, gaps
*
* All data is derived from existing CannaiQ + internal data; no new scrapes.
*/
class AdvancedV3IntelligenceDTO
{
public function __construct(
// v3.0 fields
public readonly ?array $brandPositioning = null,
public readonly ?array $trendLeadLag = null,
public readonly array $marketSignals = [],
public readonly array $shelfOpportunities = [],
// v4.0: Shelf value projections with capture scenarios
public readonly array $shelfValueProjections = [],
// v4.0: Consumer Demand Index + SKU lifecycle
public readonly ?array $consumerDemand = null,
// v4.0: Price elasticity metrics
public readonly ?array $elasticity = null,
// v4.0: Competitive threat scoring
public readonly ?array $competitiveThreat = null,
// v4.0: Portfolio balance analysis
public readonly ?array $portfolioBalance = null,
) {}
/**
* Create empty DTO when data is unavailable
*/
public static function empty(): self
{
return new self(
brandPositioning: null,
trendLeadLag: null,
marketSignals: [],
shelfOpportunities: [],
shelfValueProjections: [],
consumerDemand: null,
elasticity: null,
competitiveThreat: null,
portfolioBalance: null,
);
}
/**
* Create empty brand positioning structure
*
* Structure:
* - differentiation_score: 0-100 (how unique vs competitors)
* - positioning_label: 'more_of_the_same'|'value_disruptor'|'premium_standout'|'potency_leader'|'format_outlier'
* - comparables: Array of similar brands with distance scores
* - notes: Array of bullet explanations
*/
public static function emptyBrandPositioning(): array
{
return [
'differentiation_score' => null,
'positioning_label' => 'more_of_the_same',
'comparables' => [],
'notes' => [],
];
}
/**
* Create empty trend lead/lag structure
*
* Structure:
* - lead_lag_index: -100 (laggy) to +100 (predictive)
* - classification: 'strong_leader'|'emerging_leader'|'in_line'|'follower'|'laggy'
* - supporting_signals: Array of category-level signals
*/
public static function emptyTrendLeadLag(): array
{
return [
'lead_lag_index' => 0,
'classification' => 'in_line',
'supporting_signals' => [],
];
}
/**
* Create empty market signal structure
*
* Structure:
* - scope: 'multi_state'|'state'|'category'
* - state_code: optional state
* - category: optional category
* - description: human-readable summary
* - trend_strength: 0-100
* - relevant_to_brand: bool
* - brand_fit: 'strong_fit'|'partial_fit'|'gap'
* - example_brand: optional example
*/
public static function emptyMarketSignal(): array
{
return [
'scope' => 'category',
'state_code' => null,
'category' => null,
'description' => '',
'trend_strength' => 0,
'relevant_to_brand' => false,
'brand_fit' => 'gap',
'example_brand' => null,
];
}
/**
* Create empty shelf opportunity structure
*
* Structure:
* - store_id: CannaiQ store external ID
* - store_name: Store display name
* - state_code: State abbreviation
* - opportunity_type: 'whitespace'|'displacement'
* - competitor_brand: null for whitespace
* - competitor_product_name: null for whitespace
* - our_best_sku_id: our matching product ID
* - our_best_sku_name: our matching product name
* - est_monthly_units_current: competitor's current volume
* - est_monthly_units_if_we_win: projected volume if we win
* - est_monthly_revenue_if_we_win: projected revenue
* - quality_score_delta: -100 to +100 (positive = we're better)
* - value_score_delta: -100 to +100 (positive = better value)
* - displacement_difficulty: 'low'|'medium'|'high'
* - difficulty_score: 0-100 (100 = hardest)
* - rationale_tags: Array of reason strings
*/
public static function emptyShelfOpportunity(): array
{
return [
'store_id' => null,
'store_name' => 'Unknown',
'state_code' => null,
'opportunity_type' => 'whitespace',
'competitor_brand' => null,
'competitor_product_name' => null,
'our_best_sku_id' => null,
'our_best_sku_name' => null,
'est_monthly_units_current' => 0,
'est_monthly_units_if_we_win' => 0,
'est_monthly_revenue_if_we_win' => 0,
'quality_score_delta' => 0,
'value_score_delta' => 0,
'displacement_difficulty' => 'medium',
'difficulty_score' => 50,
'rationale_tags' => [],
];
}
/**
* Create empty shelf value projection structure (v4.0)
*
* Structure:
* - scope: 'store'|'state'|'multi_state' - geographic scope of projection
* - store_id: CannaiQ store ID (when scope='store')
* - store_name: Store display name (when scope='store')
* - state_code: State abbreviation (when scope='store' or 'state')
* - current_competitor_sales: Competitor revenue currently on shelf
* - category_total_sales: Total category sales at location
* - our_current_share: Our % of category sales (0.0-1.0)
* - our_current_shelf_value: Our current monthly revenue at location
* - avg_displacement_difficulty: 0-100 (aggregated from opportunities)
* - opportunity_label: 'Big prize, low effort'|'Low-hanging fruit'|'High potential, high difficulty'|'Grind zone'
* - capture_scenarios: Array of capture scenario projections
*/
public static function emptyShelfValueProjection(): array
{
return [
'scope' => 'store',
'store_id' => null,
'store_name' => null,
'state_code' => null,
'current_competitor_sales' => 0,
'category_total_sales' => 0,
'our_current_share' => 0,
'our_current_shelf_value' => 0,
'avg_displacement_difficulty' => 50,
'opportunity_label' => 'Grind zone',
'capture_scenarios' => [],
];
}
/**
* Create empty capture scenario structure (v4.0)
*
* Structure:
* - capture_percent: 10|25|50 - % of competitor shelf to capture
* - projected_monthly_revenue: Revenue if we achieve this capture
* - projected_units: Units if we achieve this capture
* - revenue_lift_from_current: Delta from our current revenue
* - effort_level: 'low'|'medium'|'high' - based on difficulty + capture %
*/
public static function emptyCaptureScenario(): array
{
return [
'capture_percent' => 10,
'projected_monthly_revenue' => 0,
'projected_units' => 0,
'revenue_lift_from_current' => 0,
'effort_level' => 'medium',
];
}
/**
* Get opportunity label based on value and difficulty
*
* @param float $value Estimated monthly revenue opportunity
* @param int $difficulty 0-100 difficulty score
*/
public static function getOpportunityLabel(float $value, int $difficulty): string
{
// High value threshold: $5,000/mo
// Low difficulty threshold: 40
$highValue = $value >= 5000;
$lowDifficulty = $difficulty <= 40;
return match (true) {
$highValue && $lowDifficulty => 'Big prize, low effort',
! $highValue && $lowDifficulty => 'Low-hanging fruit',
$highValue && ! $lowDifficulty => 'High potential, high difficulty',
default => 'Grind zone',
};
}
/**
* Convert to array for views
*/
public function toArray(): array
{
return [
'brandPositioning' => $this->brandPositioning,
'trendLeadLag' => $this->trendLeadLag,
'marketSignals' => $this->marketSignals,
'shelfOpportunities' => $this->shelfOpportunities,
'shelfValueProjections' => $this->shelfValueProjections,
'consumerDemand' => $this->consumerDemand,
'elasticity' => $this->elasticity,
'competitiveThreat' => $this->competitiveThreat,
'portfolioBalance' => $this->portfolioBalance,
];
}
/**
* Check if any v3/v4 intelligence data is available
*/
public function hasData(): bool
{
return $this->brandPositioning !== null
|| $this->trendLeadLag !== null
|| ! empty($this->marketSignals)
|| ! empty($this->shelfOpportunities)
|| ! empty($this->shelfValueProjections)
|| $this->consumerDemand !== null
|| $this->elasticity !== null
|| $this->competitiveThreat !== null
|| $this->portfolioBalance !== null;
}
/**
* Create empty consumer demand structure (v4.0)
*
* Structure:
* - consumer_demand_index: 0-100 overall brand demand score
* - sku_scores: Array of per-SKU demand metrics
*/
public static function emptyConsumerDemand(): array
{
return [
'consumer_demand_index' => null,
'sku_scores' => [],
];
}
/**
* Create empty SKU demand score structure (v4.0)
*
* Structure:
* - product_id: Internal product ID
* - product_name: Display name
* - demand_index: 0-100 demand score
* - promo_independence: 0-100 (higher = sells well without promos)
* - cross_store_consistency: 0-100 (higher = consistent across stores)
* - stage: 'launch'|'growth'|'peak'|'decline'|'terminal'|null
*/
public static function emptySkuDemandScore(): array
{
return [
'product_id' => null,
'product_name' => 'Unknown',
'demand_index' => null,
'promo_independence' => null,
'cross_store_consistency' => null,
'stage' => null,
];
}
/**
* Create empty elasticity structure (v4.0)
*
* Structure:
* - sku_elasticity: Array of per-SKU price elasticity metrics
*/
public static function emptyElasticity(): array
{
return [
'sku_elasticity' => [],
];
}
/**
* Create empty SKU elasticity structure (v4.0)
*
* Structure:
* - product_id: Internal product ID
* - product_name: Display name
* - current_price: Current average price
* - elasticity: Numeric elasticity coefficient (negative = price sensitive)
* - price_behavior: 'sensitive'|'stable'|'room_to_raise'|null
* - note: Human-readable recommendation
*/
public static function emptySkuElasticity(): array
{
return [
'product_id' => null,
'product_name' => 'Unknown',
'current_price' => null,
'elasticity' => null,
'price_behavior' => null,
'note' => null,
];
}
/**
* Create empty competitive threat structure (v4.0)
*
* Structure:
* - overall_threat_score: 0-100 aggregate threat level
* - threat_level: 'low'|'medium'|'high'
* - threats: Array of competitor threat details
*/
public static function emptyCompetitiveThreat(): array
{
return [
'overall_threat_score' => null,
'threat_level' => null,
'threats' => [],
];
}
/**
* Create empty competitor threat structure (v4.0)
*
* Structure:
* - brand_name: Competitor brand name
* - threat_score: 0-100 individual threat score
* - price_aggression: 0-100 (how aggressively they undercut)
* - velocity_trend: -100 to +100 (their growth vs decline)
* - overlap_score: 0-100 (category/store overlap)
* - notes: Array of threat reasons
*/
public static function emptyThreatBrand(): array
{
return [
'brand_name' => 'Unknown',
'threat_score' => null,
'price_aggression' => null,
'velocity_trend' => null,
'overlap_score' => null,
'notes' => [],
];
}
/**
* Create empty portfolio balance structure (v4.0)
*
* Structure:
* - category_mix: Array of category distribution
* - redundancy_clusters: Array of similar SKU groupings
* - gaps: Array of identified portfolio gaps
*/
public static function emptyPortfolioBalance(): array
{
return [
'category_mix' => [],
'redundancy_clusters' => [],
'gaps' => [],
];
}
/**
* Create empty category mix item structure (v4.0)
*/
public static function emptyCategoryMix(): array
{
return [
'category' => 'Unknown',
'sku_count' => 0,
'revenue_share_percent' => null,
];
}
/**
* Create empty redundancy cluster structure (v4.0)
*/
public static function emptyRedundancyCluster(): array
{
return [
'cluster_id' => null,
'label' => 'Unknown',
'product_ids' => [],
'note' => null,
];
}
/**
* Create empty portfolio gap structure (v4.0)
*/
public static function emptyPortfolioGap(): array
{
return [
'category' => 'Unknown',
'description' => null,
];
}
/**
* Get threat level label from score
*/
public static function getThreatLevel(float $score): string
{
return match (true) {
$score >= 70 => 'high',
$score >= 40 => 'medium',
default => 'low',
};
}
/**
* Get lifecycle stage from velocity metrics
*
* @param float $velocity Current daily velocity
* @param float|null $velocityTrend % change vs prior period (-100 to +100)
* @param float $categoryAvgVelocity Category average velocity
*/
public static function getLifecycleStage(float $velocity, ?float $velocityTrend, float $categoryAvgVelocity): string
{
$relativeVelocity = $categoryAvgVelocity > 0 ? $velocity / $categoryAvgVelocity : 0;
// Very low velocity with flat/declining trend = terminal
if ($relativeVelocity < 0.2 && ($velocityTrend === null || $velocityTrend <= 0)) {
return 'terminal';
}
// Low velocity but growing = launch
if ($relativeVelocity < 0.5 && $velocityTrend !== null && $velocityTrend > 20) {
return 'launch';
}
// Medium velocity with strong growth = growth
if ($velocityTrend !== null && $velocityTrend > 10) {
return 'growth';
}
// High velocity, stable = peak
if ($relativeVelocity >= 0.8 && ($velocityTrend === null || abs($velocityTrend) <= 10)) {
return 'peak';
}
// Declining = decline
if ($velocityTrend !== null && $velocityTrend < -10) {
return 'decline';
}
// Default to growth for healthy products
return 'growth';
}
/**
* Get positioning label for display
*/
public function getPositioningLabelDisplay(): string
{
if (! $this->brandPositioning) {
return 'Unknown';
}
return match ($this->brandPositioning['positioning_label'] ?? 'more_of_the_same') {
'value_disruptor' => 'Value Disruptor',
'premium_standout' => 'Premium Standout',
'potency_leader' => 'Potency Leader',
'format_outlier' => 'Format Outlier',
default => 'More of the Same',
};
}
/**
* Get trend classification for display
*/
public function getTrendClassificationDisplay(): string
{
if (! $this->trendLeadLag) {
return 'Unknown';
}
return match ($this->trendLeadLag['classification'] ?? 'in_line') {
'strong_leader' => 'Predictive (Leads Market)',
'emerging_leader' => 'Early Mover',
'follower' => 'Follower',
'laggy' => 'Laggy (Follows Late)',
default => 'In Line with Market',
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
<?php
namespace App\Services\Cannaiq;
/**
* Brand Analysis Data Transfer Object (v3.0)
*
* Contains all market intelligence data for a brand, structured for the Analysis page.
* When CannaiQ is disabled, contains only internal sales data.
* When CannaiQ is enabled, enriched with market intelligence.
*
* v2.0 Additions:
* - engagement: Buyer outreach and response tracking (always available)
* - sentiment: Store support and brand positioning (CannaiQ only)
*
* v3.0 Additions:
* - advancedV3: Advanced intelligence analytics (CannaiQ only)
* - brandPositioning: Differentiation score and positioning label
* - trendLeadLag: Predictive vs laggy behavior analysis
* - marketSignals: Cross-state market trends
* - shelfOpportunities: Displacement opportunities with difficulty scores
*
* Structure Reference (v1.5):
*
* placement: [
* 'stores' => [...], // List of stores carrying brand
* 'whitespaceStores' => [...], // v1.5: Stores with competitors but not us
* 'whitespaceCount' => int, // v1.5: Count of whitespace opportunities
* 'penetrationByRegion' => [ // v1.5: Regional breakdown
* ['region' => 'CA', 'storeCount' => 10, 'totalStores' => 50, 'penetrationPercent' => 20],
* ],
* ]
*
* competitors: [
* 'competitors' => [...], // List of competitor brands
* 'pricePosition' => 'value'|'mid'|'premium', // v1.5: Our price position
* 'headToHeadSkus' => [ // v1.5: Direct SKU comparisons
* ['ourSku' => '...', 'competitorSku' => '...', 'ourVelocity' => 0.5, ...],
* ],
* 'marketShareTrend' => [ // v1.5: Time series market share
* ['period' => '2025-01', 'ourShare' => 12.5, 'competitor1Share' => 15.2, ...],
* ],
* ]
*
* promoPerformance: [
* [
* 'id' => ..., 'name' => ...,
* 'baselineVelocity' => float, // v1.5: Non-promo velocity
* 'promoVelocity' => float, // v1.5: During-promo velocity
* 'velocityLift' => float, // v1.5: Percent lift
* 'efficiencyScore' => float, // v1.5: Units gained per discount dollar
* ],
* ]
*
* inventoryProjection: [
* 'items' => [ // v1.5: Structured items array
* ['sku' => '...', 'daysOfStock' => int, 'riskLevel' => 'low'|'medium'|'high', ...],
* ],
* 'overstockedItems' => [...], // v1.5: Items with >90 days supply
* 'rollup' => [ // v1.5: Brand-level summary
* 'criticalCount' => int,
* 'warningCount' => int,
* 'overstockedSkuCount' => int,
* 'riskLevel' => 'healthy'|'moderate'|'elevated'|'critical',
* ],
* ]
*
* slippage: [
* 'alerts' => [...], // Basic alerts (existing)
* 'summary' => [ // v1.5: Summary metrics
* 'lostStores30dCount' => int,
* 'lostStores60dCount' => int,
* 'lostSkus30dCount' => int,
* 'competitorTakeoverCount' => int,
* ],
* 'lostStores30d' => [...], // v1.5: List of lost stores
* 'lostStores60d' => [...],
* 'lostSkus30d' => [...], // v1.5: List of lost SKUs
* 'competitorTakeovers' => [...], // v1.5: SKU replacement events
* 'oosMetrics' => [ // v1.5: Out-of-stock metrics
* 'avgOOSDuration' => float,
* 'avgReorderLag' => float,
* 'chronicOOSStores' => [...],
* ],
* ]
*
* engagement: [ // v2.0: Buyer outreach & response (ALWAYS available)
* 'reach' => [
* 'storesContacted30d' => int, // Unique stores contacted
* 'messagesSent30d' => int, // Total outbound messages
* 'touchesPerStore' => float, // Avg touches per store
* 'repActivityLeaders' => [...], // Top reps by activity
* ],
* 'response' => [
* 'responseRate' => float, // 0..1 reply rate
* 'avgResponseTimeHours' => float|null, // Median reply time
* 'storesNotResponding' => int, // Silent accounts
* 'mostEngagedStores' => [...], // Top responding stores
* ],
* 'actions' => [
* 'quotesIssued30d' => int, // Quotes tied to brand
* 'ordersPlaced30d' => int, // Orders with brand products
* 'conversionRate' => float|null, // Quotes → Orders
* 'reorderRate' => float|null, // Repeat buyers
* 'atRiskAccounts' => [...], // Accounts needing attention
* ],
* 'quality' => [
* 'touchTypeBreakdown' => [...], // By channel type
* 'buyerEngagementScore' => float|null, // 0..100
* 'buyerEngagementLabel' => string, // "Strong partner" / "Healthy" / "At risk" / "Needs action"
* ],
* ]
*
* sentiment: [ // v2.0: Store support (CannaiQ ONLY - null when disabled)
* 'storeSupport' => [
* 'storesPromotingBrand30d' => int, // Stores with active promos
* 'promoFrequencyPerStore' => float|null,// Promos per store
* 'featuredPlacementCount' => int, // Featured/specials count
* 'avgShelfShare' => float|null, // Category share
* 'storeSentimentScore' => float|null, // 0..100
* 'storeSentimentLabel' => string, // "Advocates" / "Supportive" / "Neutral" / "Unsupportive"
* ],
* 'pricingBehavior' => [
* 'avgDiscountRate' => float|null, // Avg promo discount
* 'priceRespectIndex' => float|null, // 0..100 (MSRP adherence)
* 'competitorPricePressure' => float|null, // 0..100
* ],
* 'inventoryBehavior' => [
* 'sellThroughAfterRestock' => float|null, // Units/day post-restock
* 'restockUrgencyIndex' => float|null, // 0..100 (faster reorders = higher)
* 'stockNeglectEvents' => int, // Extended OOS events
* 'shelfCommitment' => [
* 'singleSkuStores' => int, // Stores with 1 SKU
* 'multiSkuStores' => int, // Stores with 3+ SKUs
* 'avgSkusPerStore' => float|null, // Avg SKU depth
* ],
* ],
* ]
*/
class BrandAnalysisDTO
{
public function __construct(
// Core metadata
public readonly int $brandId,
public readonly string $brandName,
public readonly bool $cannaiqEnabled,
public readonly ?\DateTimeInterface $dataFreshness = null,
// Connection error message (when CannaiQ is enabled but API fails)
public readonly ?string $connectionError = null,
// Store placement data (v1.5: enriched with whitespace + regional)
public readonly array $placement = [],
// Competitor analysis (v1.5: enriched with head-to-head + trends)
public readonly array $competitors = [],
// SKU performance data
public readonly array $skuPerformance = [],
// Promo performance data (v1.5: enriched with lift + efficiency)
public readonly array $promoPerformance = [],
// Inventory projections (v1.5: enriched with risk levels + rollup)
public readonly array $inventoryProjection = [],
// Slippage/velocity warnings (v1.5: fully structured)
public readonly array $slippage = [],
// Summary metrics (v1.5: enriched with whitespace count)
public readonly array $summary = [],
// v2.0: Buyer engagement (internal CRM + orders - ALWAYS available)
public readonly array $engagement = [],
// v2.0: Store sentiment (CannaiQ data - ONLY when cannaiq_enabled)
public readonly ?array $sentiment = null,
// v3.0: Advanced intelligence (CannaiQ data - ONLY when cannaiq_enabled)
public readonly ?AdvancedV3IntelligenceDTO $advancedV3 = null,
) {}
/**
* Create empty DTO for when data is unavailable
*/
public static function empty(int $brandId, string $brandName, bool $cannaiqEnabled): self
{
return new self(
brandId: $brandId,
brandName: $brandName,
cannaiqEnabled: $cannaiqEnabled,
dataFreshness: null,
placement: [
'stores' => [],
'whitespaceStores' => [],
'whitespaceCount' => 0,
'penetrationByRegion' => [],
],
competitors: [
'competitors' => [],
'pricePosition' => null,
'headToHeadSkus' => [],
'marketShareTrend' => [],
],
skuPerformance: [],
promoPerformance: [],
inventoryProjection: [
'items' => [],
'overstockedItems' => [],
'rollup' => [
'criticalCount' => 0,
'warningCount' => 0,
'overstockedSkuCount' => 0,
'riskLevel' => 'healthy',
],
],
slippage: [
'alerts' => [],
'summary' => [
'lostStores30dCount' => 0,
'lostStores60dCount' => 0,
'lostSkus30dCount' => 0,
'competitorTakeoverCount' => 0,
],
'lostStores30d' => [],
'lostStores60d' => [],
'lostSkus30d' => [],
'competitorTakeovers' => [],
'oosMetrics' => [
'avgOOSDuration' => null,
'avgReorderLag' => null,
'chronicOOSStores' => [],
],
],
summary: [
'totalStores' => 0,
'totalSkus' => 0,
'avgPrice' => 0,
'marketShare' => null,
'pricePosition' => null,
'whitespaceCount' => 0,
],
engagement: self::emptyEngagement(),
sentiment: null,
advancedV3: null,
);
}
/**
* Get empty engagement structure
*/
public static function emptyEngagement(): array
{
return [
'reach' => [
'storesContacted30d' => 0,
'messagesSent30d' => 0,
'touchesPerStore' => 0,
'repActivityLeaders' => [],
],
'response' => [
'responseRate' => 0,
'avgResponseTimeHours' => null,
'storesNotResponding' => 0,
'mostEngagedStores' => [],
],
'actions' => [
'quotesIssued30d' => 0,
'ordersPlaced30d' => 0,
'conversionRate' => null,
'reorderRate' => null,
'atRiskAccounts' => [],
],
'quality' => [
'touchTypeBreakdown' => [],
'buyerEngagementScore' => null,
'buyerEngagementLabel' => 'Needs action',
],
];
}
/**
* Get empty sentiment structure
*/
public static function emptySentiment(): array
{
return [
'storeSupport' => [
'storesPromotingBrand30d' => 0,
'promoFrequencyPerStore' => null,
'featuredPlacementCount' => 0,
'avgShelfShare' => null,
'storeSentimentScore' => null,
'storeSentimentLabel' => 'Neutral',
],
'pricingBehavior' => [
'avgDiscountRate' => null,
'priceRespectIndex' => null,
'competitorPricePressure' => null,
],
'inventoryBehavior' => [
'sellThroughAfterRestock' => null,
'restockUrgencyIndex' => null,
'stockNeglectEvents' => 0,
'shelfCommitment' => [
'singleSkuStores' => 0,
'multiSkuStores' => 0,
'avgSkusPerStore' => null,
],
],
];
}
/**
* Convert to array for views
*/
public function toArray(): array
{
return [
'brandId' => $this->brandId,
'brandName' => $this->brandName,
'cannaiqEnabled' => $this->cannaiqEnabled,
'connectionError' => $this->connectionError,
'dataFreshness' => $this->dataFreshness?->format('Y-m-d H:i:s'),
'placement' => $this->placement,
'competitors' => $this->competitors,
'skuPerformance' => $this->skuPerformance,
'promoPerformance' => $this->promoPerformance,
'inventoryProjection' => $this->inventoryProjection,
'slippage' => $this->slippage,
'summary' => $this->summary,
'engagement' => $this->engagement,
'sentiment' => $this->sentiment,
'advancedV3' => $this->advancedV3?->toArray(),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -437,4 +437,135 @@ class CannaiqClient
];
}
}
// ========================================
// Brand Analytics API Endpoints (v1.5)
// These endpoints provide brand-level intelligence
// ========================================
/**
* Get brand-level metrics including whitespace and regional penetration
*
* @param string $brandName Brand name/slug
*/
public function getBrandMetrics(string $brandName): array
{
try {
$response = $this->http->get("/brands/{$brandName}/metrics");
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand metrics', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand metrics'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand metrics', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get competitor analysis for a brand
* Returns: head-to-head comparisons, market share trends, price position
*
* @param string $brandName Brand name/slug
* @param array $options Optional parameters (top_n, etc)
*/
public function getBrandCompetitors(string $brandName, array $options = []): array
{
try {
$response = $this->http->get("/brands/{$brandName}/competitors", $options);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand competitors', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand competitors'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand competitors', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get promotion performance metrics for a brand
* Returns: velocity lift, baseline vs promo velocity, efficiency scores
*
* @param string $brandName Brand name/slug
* @param array $options Optional parameters (from, to date range)
*/
public function getBrandPromoMetrics(string $brandName, array $options = []): array
{
try {
$response = $this->http->get("/brands/{$brandName}/promo-metrics", $options);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand promo metrics', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand promo metrics'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand promo metrics', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get slippage/churn metrics for a brand
* Returns: lost stores, lost SKUs, competitor takeovers, OOS metrics
*
* @param string $brandName Brand name/slug
* @param array $options Optional parameters (days_back, etc)
*/
public function getBrandSlippage(string $brandName, array $options = []): array
{
try {
$response = $this->http->get("/brands/{$brandName}/slippage", $options);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand slippage', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand slippage'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand slippage', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
}

View File

@@ -0,0 +1,227 @@
<?php
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Backfill descriptions from MySQL hub_cannabrands database.
*
* This migration:
* 1. Updates existing products with long_description from product_extras
* 2. Creates missing Nuvata products (NU-* SKUs)
* 3. Updates existing brands with tagline, description, long_description
*
* Idempotent: Only updates null fields, won't overwrite existing data.
*/
return new class extends Migration
{
protected string $mysqlConnection = 'mysql_import';
public function up(): void
{
// Check if mysql_import connection is configured
if (! config('database.connections.mysql_import')) {
echo "Skipping: mysql_import connection not configured.\n";
return;
}
try {
DB::connection($this->mysqlConnection)->getPdo();
} catch (\Exception $e) {
echo "Skipping: Cannot connect to mysql_import database.\n";
return;
}
$this->backfillProductDescriptions();
$this->importNuvataProducts();
$this->backfillBrandDescriptions();
}
/**
* Update existing products with long_description from product_extras.
*/
protected function backfillProductDescriptions(): void
{
echo "Backfilling product long_descriptions...\n";
// Get all products with long_description from MySQL
$mysqlProducts = DB::connection($this->mysqlConnection)
->table('products')
->join('product_extras', 'products.id', '=', 'product_extras.product_id')
->whereNotNull('product_extras.long_description')
->where('product_extras.long_description', '!=', '')
->select('products.code as sku', 'product_extras.long_description')
->get();
echo "Found {$mysqlProducts->count()} products with long_description in MySQL.\n";
$updated = 0;
$skipped = 0;
foreach ($mysqlProducts as $mysqlProduct) {
// Find matching product in PostgreSQL by SKU
$pgProduct = Product::where('sku', $mysqlProduct->sku)->first();
if (! $pgProduct) {
$skipped++;
continue;
}
// Only update if long_description is null/empty
if (empty($pgProduct->long_description)) {
$pgProduct->update([
'long_description' => $mysqlProduct->long_description,
]);
$updated++;
} else {
$skipped++;
}
}
echo "Updated {$updated} products, skipped {$skipped} (not found or already has data).\n";
}
/**
* Import missing Nuvata products (NU-* SKUs).
*/
protected function importNuvataProducts(): void
{
echo "Importing missing Nuvata products...\n";
// Find Nuvata brand in PostgreSQL
$nuvataBrand = Brand::where('slug', 'nuvata')->first();
if (! $nuvataBrand) {
echo "Nuvata brand not found in PostgreSQL. Skipping Nuvata product import.\n";
return;
}
// Get Nuvata products from MySQL
$nuvataProducts = DB::connection($this->mysqlConnection)
->table('products')
->leftJoin('product_extras', 'products.id', '=', 'product_extras.product_id')
->where('products.code', 'like', 'NU-%')
->select(
'products.code as sku',
'products.name',
'products.description',
'products.wholesale_price',
'products.active',
'products.created_at',
'products.updated_at',
'product_extras.long_description'
)
->get();
echo "Found {$nuvataProducts->count()} Nuvata products in MySQL.\n";
$created = 0;
$skipped = 0;
foreach ($nuvataProducts as $mysqlProduct) {
// Check if product already exists
if (Product::where('sku', $mysqlProduct->sku)->exists()) {
$skipped++;
continue;
}
// Generate unique hashid
$hashid = Str::random(8);
while (Product::where('hashid', $hashid)->exists()) {
$hashid = Str::random(8);
}
Product::create([
'brand_id' => $nuvataBrand->id,
'hashid' => $hashid,
'sku' => $mysqlProduct->sku,
'name' => $mysqlProduct->name,
'description' => $mysqlProduct->description,
'long_description' => $mysqlProduct->long_description,
'wholesale_price' => $mysqlProduct->wholesale_price,
'is_active' => (bool) $mysqlProduct->active,
'created_at' => $mysqlProduct->created_at,
'updated_at' => $mysqlProduct->updated_at,
]);
$created++;
}
echo "Created {$created} Nuvata products, skipped {$skipped} existing.\n";
}
/**
* Update brands with tagline, description, and long_description.
*/
protected function backfillBrandDescriptions(): void
{
echo "Backfilling brand descriptions...\n";
// Get brands from MySQL with descriptions
$mysqlBrands = DB::connection($this->mysqlConnection)
->table('brands')
->select('name', 'tagline', 'short_desc', 'desc')
->get();
echo "Found {$mysqlBrands->count()} brands in MySQL.\n";
$updated = 0;
$skipped = 0;
foreach ($mysqlBrands as $mysqlBrand) {
$name = trim($mysqlBrand->name ?? '');
if (! $name) {
$skipped++;
continue;
}
// Find matching brand in PostgreSQL by name (case-insensitive)
$pgBrand = Brand::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
if (! $pgBrand) {
$skipped++;
continue;
}
$updates = [];
// Only update null/empty fields
if (empty($pgBrand->tagline) && ! empty($mysqlBrand->tagline)) {
$updates['tagline'] = $mysqlBrand->tagline;
}
if (empty($pgBrand->description) && ! empty($mysqlBrand->short_desc)) {
$updates['description'] = $mysqlBrand->short_desc;
}
if (empty($pgBrand->long_description) && ! empty($mysqlBrand->desc)) {
$updates['long_description'] = $mysqlBrand->desc;
}
if (! empty($updates)) {
$pgBrand->update($updates);
$updated++;
} else {
$skipped++;
}
}
echo "Updated {$updated} brands, skipped {$skipped} (not found or already has data).\n";
}
public function down(): void
{
// This is a data backfill migration - no rollback needed
// Data was only added to null fields, original data preserved
}
};

View File

@@ -247,15 +247,15 @@ html:not([data-theme="material"], [data-theme="material-dark"]) {
/* == Cannabrands Brand Colors ==
* DO NOT CHANGE THESE COLORS!
* Primary: #4B6FA4 (muted blue)
* Success: #4E8D71 (muted green)
* Primary: #4E8D71 (green - main action color)
* Info: #4B6FA4 (blue - informational)
* Error: #E1524D (clean red)
* These are the official brand colors. */
--color-primary: #4B6FA4;
--color-primary: #4E8D71;
--color-primary-content: #ffffff;
--color-secondary: #4B6FA4;
--color-secondary-content: #ffffff;
--color-accent: #4B6FA4;
--color-accent: #4E8D71;
--color-accent-content: #ffffff;
--color-neutral: #374151;
--color-neutral-content: #ffffff;
@@ -302,11 +302,11 @@ html:not([data-theme="material"], [data-theme="material-dark"]) {
/* == Cannabrands Brand Colors (Dark) ==
* DO NOT CHANGE THESE COLORS!
* See light theme for brand color documentation. */
--color-primary: #5A7FB4;
--color-primary: #5E9D81;
--color-primary-content: #ffffff;
--color-secondary: #5A7FB4;
--color-secondary-content: #ffffff;
--color-accent: #5A7FB4;
--color-accent: #5E9D81;
--color-accent-content: #ffffff;
--color-neutral: #9ca3af;
--color-neutral-content: #1e2832;

View File

@@ -5,24 +5,21 @@
--}}
@php
$statusConfig = [
// New orders (primary - blue)
'new' => ['label' => 'New', 'class' => 'badge-primary'],
// All active/in-progress states (neutral - gray)
'buyer_modified' => ['label' => 'Modified', 'class' => 'badge-neutral'],
'seller_modified' => ['label' => 'Modified', 'class' => 'badge-neutral'],
'accepted' => ['label' => 'Accepted', 'class' => 'badge-neutral'],
'in_progress' => ['label' => 'In Progress', 'class' => 'badge-neutral'],
'ready_for_delivery' => ['label' => 'Buyer Review', 'class' => 'badge-neutral'],
'out_for_delivery' => ['label' => 'Delivering', 'class' => 'badge-neutral'],
'ready_for_manifest' => ['label' => 'Ready', 'class' => 'badge-neutral'],
'approved_for_delivery' => ['label' => 'Ready', 'class' => 'badge-neutral'],
'delivered' => ['label' => 'Delivered', 'class' => 'badge-neutral'],
'buyer_approved' => ['label' => 'Complete', 'class' => 'badge-neutral'],
'completed' => ['label' => 'Complete', 'class' => 'badge-neutral'],
// Terminal error states only (error - muted red)
'cancelled' => ['label' => 'Cancelled', 'class' => 'badge-error'],
'rejected' => ['label' => 'Rejected', 'class' => 'badge-error'],
// All statuses use ghost (no color) style
'new' => ['label' => 'New', 'class' => 'badge-ghost'],
'buyer_modified' => ['label' => 'Modified', 'class' => 'badge-ghost'],
'seller_modified' => ['label' => 'Modified', 'class' => 'badge-ghost'],
'accepted' => ['label' => 'Accepted', 'class' => 'badge-ghost'],
'in_progress' => ['label' => 'In Progress', 'class' => 'badge-ghost'],
'ready_for_delivery' => ['label' => 'Buyer Review', 'class' => 'badge-ghost'],
'out_for_delivery' => ['label' => 'Delivering', 'class' => 'badge-ghost'],
'ready_for_manifest' => ['label' => 'Ready', 'class' => 'badge-ghost'],
'approved_for_delivery' => ['label' => 'Ready', 'class' => 'badge-ghost'],
'delivered' => ['label' => 'Delivered', 'class' => 'badge-ghost'],
'buyer_approved' => ['label' => 'Complete', 'class' => 'badge-ghost'],
'completed' => ['label' => 'Complete', 'class' => 'badge-ghost'],
'cancelled' => ['label' => 'Cancelled', 'class' => 'badge-ghost'],
'rejected' => ['label' => 'Rejected', 'class' => 'badge-ghost'],
];
$config = $statusConfig[$status] ?? ['label' => ucfirst(str_replace('_', ' ', $status)), 'class' => 'badge-neutral'];

View File

@@ -165,6 +165,9 @@
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
@if($appBuildDate ?? null)
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
@endif
<p>&copy; {{ date('Y') }} creationshop, {{ config('version.company.suffix') }}</p>
</div>
</div>

View File

@@ -178,13 +178,16 @@
<!-- 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="mb-0.5" style="font-family: 'Courier New', monospace;">
<p class="mb-0.5 font-mono">
@if($appVersion === 'dev')
<span class="text-yellow-500 font-semibold">DEV</span> sha-{{ $appCommit }}
@else
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
@if($appBuildDate ?? null)
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
@endif
<p>&copy; {{ date('Y') }} Made with <span class="text-error">&hearts;</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover text-xs">Creationshop</a></p>
</div>

View File

@@ -179,6 +179,9 @@
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
@if($appBuildDate ?? null)
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
@endif
<p>&copy; {{ date('Y') }} creationshop, {{ config('version.company.suffix') }}</p>
</div>
</div>

View File

@@ -25,20 +25,11 @@
<div class="size-full overflow-y-auto"
x-data="{
init() {
// Wait for collapse animations to complete (they take ~300ms)
setTimeout(() => {
// Scroll the active menu item into view
const activeItem = this.$el.querySelector('.menu-item.active');
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Fallback to saved scroll position
const savedScroll = localStorage.getItem('sidebar-scroll-position');
if (savedScroll) {
this.$el.scrollTop = parseInt(savedScroll);
}
}
}, 350);
// Restore scroll position immediately (no animation)
const savedScroll = localStorage.getItem('sidebar-scroll-position');
if (savedScroll) {
this.$el.scrollTop = parseInt(savedScroll);
}
}
}"
@scroll.debounce.150ms="localStorage.setItem('sidebar-scroll-position', $el.scrollTop)">
@@ -62,7 +53,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuDashboard" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
@@ -99,7 +89,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuCommerce" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--shopping-cart] size-4"></span>
@@ -145,7 +134,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuBrands" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--bookmark] size-4"></span>
@@ -176,7 +164,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuInventory" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--package-2] size-4"></span>
@@ -247,7 +234,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuProcessing" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--beaker] size-4"></span>
@@ -268,7 +254,6 @@
aria-label="Solventless submenu"
type="checkbox"
class="peer"
name="sidebar-menu-child-item"
x-model="menuSolventless" />
<div class="collapse-title px-2.5 py-1.5">
<span class="grow text-sm font-semibold">Solventless</span>
@@ -316,7 +301,6 @@
aria-label="BHO submenu"
type="checkbox"
class="peer"
name="sidebar-menu-child-item"
x-model="menuBHO" />
<div class="collapse-title px-2.5 py-1.5">
<span class="grow text-sm font-semibold">BHO</span>
@@ -364,7 +348,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuManufacturing" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--factory] size-4"></span>
@@ -410,7 +393,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuManagement" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--book-open] size-4"></span>
@@ -444,7 +426,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuGrowth" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--megaphone] size-4"></span>
@@ -513,7 +494,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuSales" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--briefcase] size-4"></span>
@@ -574,7 +554,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuInbox" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--inbox] size-4"></span>
@@ -655,7 +634,6 @@
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuReports" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--file-bar-chart] size-4"></span>
@@ -693,6 +671,9 @@
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
@if($appBuildDate ?? null)
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
@endif
<p>&copy; {{ date('Y') }} creationshop, {{ config('version.company.suffix') }}</p>
</div>
</div>

View File

@@ -82,9 +82,10 @@
name="{{ $searchName }}"
value="{{ $searchValue }}"
placeholder="{{ $searchPlaceholder }}"
class="input input-sm input-bordered w-full pr-8 bg-base-100">
<button type="submit" class="absolute right-1.5 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle">
<span class="icon-[heroicons--magnifying-glass] size-4 text-base-content/30"></span>
class="input input-sm input-bordered w-full pr-10 bg-base-100"
onkeydown="if(event.key==='Enter'){this.form.submit();}">
<button type="submit" class="absolute right-2.5 top-1/2 -translate-y-1/2" title="Search">
<span class="icon-[heroicons--magnifying-glass] size-4 text-base-content/30 hover:text-base-content/60"></span>
</button>
</div>

View File

@@ -1,10 +1,13 @@
<div class="fi-footer flex items-center justify-center gap-x-4 py-3 text-xs text-gray-500 dark:text-gray-400">
<div>
<div class="text-center">
@if ($appVersion === 'dev')
<span class="font-semibold text-yellow-600 dark:text-yellow-500">DEV</span>
<span class="font-mono">sha-{{ $appCommit }}</span>
@else
<span class="font-mono">v{{ $appVersion }} (sha-{{ $appCommit }})</span>
@endif
@if($appBuildDate ?? null)
<span class="text-gray-400 dark:text-gray-500 ml-2">{{ $appBuildDate }}</span>
@endif
</div>
</div>

View File

@@ -0,0 +1,37 @@
<x-filament-panels::page>
{{ $this->form }}
<div class="mt-6 flex gap-3">
<x-filament::button
type="button"
color="info"
wire:click="testConnection"
>
<x-slot name="icon">
<x-heroicon-o-signal class="w-5 h-5" />
</x-slot>
Test Connection
</x-filament::button>
<x-filament::button
type="button"
color="gray"
outlined
wire:click="clearCache"
>
<x-slot name="icon">
<x-heroicon-o-trash class="w-5 h-5" />
</x-slot>
Clear Cache
</x-filament::button>
<x-filament::link
href="https://cannaiq.co"
target="_blank"
color="gray"
>
Visit CannaiQ
<x-heroicon-o-arrow-top-right-on-square class="w-4 h-4 ml-1" />
</x-filament::link>
</div>
</x-filament-panels::page>

View File

@@ -333,7 +333,7 @@
<option value="">Select source batch...</option>
@foreach($componentBatches as $batch)
<option value="{{ $batch->id }}">
{{ $batch->batch_number }} - {{ $batch->component->name ?? 'Unknown' }} ({{ $batch->quantity_remaining }} {{ $batch->quantity_unit }} remaining)
{{ $batch->batch_number }} - {{ $batch->product->name ?? 'Unknown' }} ({{ $batch->quantity_remaining }} {{ $batch->quantity_unit }} remaining)
</option>
@endforeach
</select>

View File

@@ -0,0 +1,68 @@
@extends('layouts.seller')
@section('title', 'Brand Analysis - ' . $brand->name)
@section('content')
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-16">
{{-- Icon --}}
<div class="flex justify-center mb-6">
<div class="bg-primary/10 rounded-full p-6">
<span class="icon-[lucide--bar-chart-3] size-16 text-primary"></span>
</div>
</div>
{{-- Title --}}
<h1 class="text-3xl font-bold mb-4">Brand Analysis</h1>
<h2 class="text-xl text-base-content/70 mb-6">{{ $brand->name }}</h2>
{{-- Message --}}
<div class="max-w-lg mx-auto mb-8">
<p class="text-base-content/70 mb-4">
Brand Analysis provides powerful market intelligence powered by CannaiQ, including:
</p>
<ul class="text-left text-base-content/60 space-y-2 mb-6">
<li class="flex items-center gap-2">
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
<span>Store placement and whitespace opportunities</span>
</li>
<li class="flex items-center gap-2">
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
<span>Competitor analysis and market positioning</span>
</li>
<li class="flex items-center gap-2">
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
<span>SKU velocity and performance tracking</span>
</li>
<li class="flex items-center gap-2">
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
<span>Inventory projections and slippage alerts</span>
</li>
<li class="flex items-center gap-2">
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
<span>Buyer engagement and sentiment scoring</span>
</li>
</ul>
<p class="text-base-content/70">
This feature requires CannaiQ integration to be enabled for your account.
</p>
</div>
{{-- CTA --}}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="mailto:support@cannabrands.com?subject=Enable CannaiQ for {{ urlencode($business->name) }}&body=Hi,%0A%0AI'd like to enable CannaiQ Brand Analysis for my business.%0A%0ABusiness: {{ urlencode($business->name) }}%0ABrand: {{ urlencode($brand->name) }}%0A%0AThank you!"
class="btn btn-primary gap-2">
<span class="icon-[lucide--mail] size-5"></span>
Contact Support to Enable
</a>
<a href="{{ route('seller.business.brands.dashboard', [$business->slug, $brand->hashid]) }}"
class="btn btn-ghost gap-2">
<span class="icon-[lucide--arrow-left] size-5"></span>
Back to Brand Dashboard
</a>
</div>
</div>
</div>
</div>
@endsection

File diff suppressed because it is too large Load Diff

View File

@@ -199,7 +199,7 @@
</div>
{{-- Brand Insights Section (Mini Tiles) --}}
@if(isset($brandInsights))
@if(!empty($brandInsights) && isset($brandInsights['topPerformer']))
<div class="space-y-3">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--light-bulb] size-4 text-base-content/50"></span>
@@ -348,11 +348,11 @@
</tr>
</thead>
<tbody>
@forelse($brand->products->take(5) as $product)
@forelse($brand->products->filter(fn($p) => !empty($p->hashid))->take(5) as $product)
<tr class="hover:bg-base-200/30 transition-colors">
<td class="pl-5">
<div class="flex items-center gap-3">
@if($product->image_path)
@if($product->image_path && $product->hashid)
<div class="avatar">
<div class="w-10 h-10 rounded-lg bg-base-200/50">
<img src="{{ route('image.product', [$product->hashid, 80]) }}" alt="{{ $product->name }}" class="object-contain" />

View File

@@ -23,7 +23,7 @@
</div>
</div>
<form method="POST" action="{{ route('seller.business.crm.accounts.contacts.update', [$business->slug, $account->slug, $contact->id]) }}">
<form method="POST" action="{{ route('seller.business.crm.accounts.contacts.update', [$business->slug, $account->slug, $contact->hashid]) }}">
@csrf
@method('PUT')
@@ -135,7 +135,7 @@
<h3 class="font-semibold text-error mb-2">Danger Zone</h3>
<p class="text-sm text-base-content/60 mb-4">Permanently delete this contact. This action cannot be undone.</p>
<form method="POST"
action="{{ route('seller.business.crm.accounts.contacts.destroy', [$business->slug, $account->slug, $contact->id]) }}"
action="{{ route('seller.business.crm.accounts.contacts.destroy', [$business->slug, $account->slug, $contact->hashid]) }}"
onsubmit="return confirm('Are you sure you want to delete this contact? This action cannot be undone.')">
@csrf
@method('DELETE')

View File

@@ -3,26 +3,27 @@
@section('content')
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
{{-- Page Header --}}
<header class="flex items-center justify-between">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold">All Customers</h1>
<p class="text-sm text-base-content/60">Manage your customer relationships and account activity.</p>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 self-start sm:self-auto">
<a href="{{ route('seller.business.crm.accounts.create', $business->slug) }}" class="btn btn-primary btn-sm gap-1">
<span class="icon-[heroicons--plus] size-4"></span>
Add Customer
<span class="hidden sm:inline">Add Customer</span>
<span class="sm:hidden">Add</span>
</a>
<a href="{{ route('seller.business.crm.leads.index', $business->slug) }}" class="btn btn-outline btn-sm gap-1">
<span class="icon-[heroicons--user-plus] size-4"></span>
Leads
<span class="hidden sm:inline">Leads</span>
</a>
{{-- Quick Actions Dropdown --}}
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-sm bg-base-100 rounded-lg w-52 border border-base-300">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-52 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.crm.leads.create', $business->slug) }}" class="gap-2">
<span class="icon-[heroicons--user-plus] size-4"></span>
@@ -91,9 +92,9 @@
<thead class="bg-base-200/30">
<tr>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Account</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Primary Contact</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3">Orders</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3">Open Opps</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden md:table-cell">Primary Contact</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3 hidden lg:table-cell">Orders</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3 hidden lg:table-cell">Open Opps</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Status</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3"></th>
</tr>
@@ -121,7 +122,7 @@
</div>
</div>
</td>
<td class="py-2 align-middle">
<td class="py-2 align-middle hidden md:table-cell">
@php $primaryContact = $account->contacts->where('is_primary', true)->first() ?? $account->contacts->first(); @endphp
@if($primaryContact)
<p class="text-sm font-medium">{{ $primaryContact->getFullName() }}</p>
@@ -132,17 +133,17 @@
<p class="text-sm text-base-content/40">No contact</p>
@endif
</td>
<td class="py-2 align-middle text-right">
<td class="py-2 align-middle text-right hidden lg:table-cell">
<span class="text-sm text-base-content/70">{{ $account->orders_count ?? 0 }}</span>
</td>
<td class="py-2 align-middle text-right">
<td class="py-2 align-middle text-right hidden lg:table-cell">
@if(($account->opportunities_as_buyer_count ?? 0) > 0)
<span class="text-sm font-medium">{{ $account->opportunities_as_buyer_count }}</span>
@else
<span class="text-sm text-base-content/40">0</span>
@endif
</td>
<td class="py-2 align-middle">
<td class="py-2 align-middle hidden sm:table-cell">
@if($account->is_active)
<span class="badge badge-success badge-sm">Active</span>
@else

View File

@@ -3,12 +3,12 @@
@section('content')
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
{{-- Page Header --}}
<header class="mb-4 flex items-center justify-between gap-3">
<header class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold mb-0.5">Automations</h1>
<p class="text-sm text-base-content/70">Build workflows that trigger campaigns, tasks, and updates based on buyer activity.</p>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 self-start sm:self-auto">
<a href="{{ route('seller.business.crm.automations.create', $business) }}" class="btn btn-primary btn-sm gap-1">
<span class="icon-[heroicons--plus] size-4"></span>
New Automation
@@ -40,10 +40,10 @@
<thead>
<tr>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Name</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Trigger</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap text-center">Runs</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap hidden md:table-cell">Trigger</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap text-center hidden lg:table-cell">Runs</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Status</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Last Run</th>
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap hidden md:table-cell">Last Run</th>
<th class="text-xs font-semibold text-base-content/70 text-right"></th>
</tr>
</thead>
@@ -58,10 +58,10 @@
<p class="text-[11px] text-base-content/60 truncate max-w-xs">{{ $automation->description }}</p>
@endif
</td>
<td class="py-2 text-sm">
<td class="py-2 text-sm hidden md:table-cell">
{{ ucwords(str_replace('_', ' ', $automation->trigger_type)) }}
</td>
<td class="py-2 text-center">
<td class="py-2 text-center hidden lg:table-cell">
<span class="text-sm text-base-content/80">{{ $automation->successful_runs ?? 0 }}</span>
</td>
<td class="py-2">
@@ -76,7 +76,7 @@
@endif
</div>
</td>
<td class="py-2 text-sm whitespace-nowrap">
<td class="py-2 text-sm whitespace-nowrap hidden md:table-cell">
{{ $automation->last_run_at?->diffForHumans() ?? 'Never' }}
</td>
<td class="py-2 text-right">
@@ -90,10 +90,10 @@
{{-- Actions Dropdown --}}
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.crm.automations.show', [$business, $automation]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -38,10 +38,10 @@
</form>
@endif
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-5"></span>
</label>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box w-48">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.crm.calendar.index', $business->slug) }}?date={{ $event->start_at->format('Y-m-d') }}">
<span class="icon-[heroicons--calendar] size-4"></span>

View File

@@ -187,7 +187,7 @@
</li>
@endif
<li class="border-t mt-1 pt-1">
<form method="POST" action="{{ route('seller.business.crm.contacts.destroy', [$business->slug, $contact->id]) }}"
<form method="POST" action="{{ route('seller.business.crm.contacts.destroy', [$business->slug, $contact->hashid]) }}"
onsubmit="return confirm('Archive this contact?')">
@csrf
@method('DELETE')

View File

@@ -27,12 +27,10 @@
</form>
@endif
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li><a href="{{ route('seller.business.crm.quotes.create', ['business' => $business, 'deal_id' => $deal->id]) }}">Create Quote</a></li>
<li>
<form method="POST" action="{{ route('seller.business.crm.deals.destroy', [$business, $deal]) }}" onsubmit="return confirm('Delete this deal?')">

View File

@@ -3,7 +3,7 @@
@section('content')
<div class="px-4 py-6 max-w-7xl mx-auto">
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<p class="text-lg font-medium flex items-center gap-2">
<span class="icon-[heroicons--user-plus] size-5"></span>
@@ -13,18 +13,11 @@
Prospects and potential customers
</p>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 self-start sm:self-auto">
<a href="{{ route('seller.business.crm.leads.create', $business->slug) }}" class="btn btn-primary btn-sm gap-2">
<span class="icon-[heroicons--plus] size-4"></span>
Add Lead
</a>
<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.crm.accounts.index', $business->slug) }}">Customers</a></li>
<li class="opacity-80">Leads</li>
</ul>
</div>
</div>
</div>
@@ -77,9 +70,9 @@
<thead>
<tr>
<th>Company</th>
<th>Contact</th>
<th>Location</th>
<th>Source</th>
<th class="hidden sm:table-cell">Contact</th>
<th class="hidden lg:table-cell">Location</th>
<th class="hidden md:table-cell">Source</th>
<th>Status</th>
<th></th>
</tr>
@@ -105,7 +98,7 @@
</div>
</div>
</td>
<td>
<td class="hidden sm:table-cell">
<div>
<p class="font-medium text-sm">{{ $lead->contact_name }}</p>
@if($lead->contact_email)
@@ -113,14 +106,14 @@
@endif
</div>
</td>
<td>
<td class="hidden lg:table-cell">
@if($lead->city || $lead->state)
<span class="text-sm">{{ $lead->city }}{{ $lead->state ? ', ' . $lead->state : '' }}</span>
@else
<span class="text-xs text-base-content/40">-</span>
@endif
</td>
<td>
<td class="hidden md:table-cell">
@if($lead->source)
<span class="text-sm">{{ \App\Models\Crm\CrmLead::SOURCES[$lead->source] ?? $lead->source }}</span>
@else
@@ -132,12 +125,12 @@
{{ \App\Models\Crm\CrmLead::STATUSES[$lead->status] ?? $lead->status }}
</span>
</td>
<td>
<td class="py-2 align-middle text-right">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-10 menu menu-sm p-2 shadow-lg bg-base-100 rounded-box w-52">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.crm.leads.show', [$business->slug, $lead->hashid]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -127,12 +127,12 @@
<span class="text-sm text-base-content/40">-</span>
@endif
</td>
<td class="py-2 text-right">
<td class="py-2 align-middle text-right">
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-sm bg-base-100 rounded-lg w-44 z-[100] border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.crm.quotes.show', [$business, $quote]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -67,12 +67,10 @@
{{-- Actions dropdown --}}
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
@if($thread->status !== 'closed')
<li>
<form method="POST" action="{{ route('seller.business.crm.threads.close', [$business, $thread]) }}">

View File

@@ -149,10 +149,10 @@
{{-- Actions --}}
<td>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li><a><span class="icon-[heroicons--eye] size-4"></span> View Product</a></li>
<li><a><span class="icon-[heroicons--pencil] size-4"></span> Edit Product</a></li>
<li><a><span class="icon-[heroicons--cube] size-4"></span> Adjust Stock</a></li>

View File

@@ -3,17 +3,17 @@
@section('content')
<div class="container-fluid py-6" x-data="invoiceCreator()">
<!-- Page Header -->
<div class="flex justify-between items-center mb-6">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-6">
<div>
<h1 class="text-3xl font-bold text-base-content flex items-center gap-2">
<span class="icon-[heroicons--document-plus] size-8"></span>
<h1 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
<span class="icon-[heroicons--document-plus] size-6 sm:size-8"></span>
Create Manual Invoice
</h1>
<p class="text-base-content/60 mt-1">Create a new invoice for an existing customer</p>
<p class="text-base-content/60 mt-1 text-sm">Create a new invoice for an existing customer</p>
</div>
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[heroicons--arrow-left] size-5"></span>
Back to Invoices
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost btn-sm self-start sm:self-auto">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back
</a>
</div>
@@ -225,12 +225,12 @@
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="w-10"></th>
<th class="w-10 hidden sm:table-cell"></th>
<th>Product <span class="text-error">*</span></th>
<th>Quantity <span class="text-error">*</span></th>
<th>Wholesale Unit Price <span class="text-error">*</span></th>
<th>Discount</th>
<th>Notes</th>
<th>Qty <span class="text-error">*</span></th>
<th class="hidden md:table-cell">Price <span class="text-error">*</span></th>
<th class="hidden lg:table-cell">Discount</th>
<th class="hidden xl:table-cell">Notes</th>
<th>Total</th>
<th></th>
</tr>
@@ -239,7 +239,7 @@
<template x-for="(item, index) in lineItems" :key="index">
<tr>
<!-- Product Image -->
<td>
<td class="hidden sm:table-cell">
<div class="avatar" x-show="item.image_url">
<div class="w-10 h-10 rounded">
<img :src="item.image_url" :alt="item.product_name" />
@@ -311,7 +311,7 @@
</td>
<!-- Unit Price -->
<td class="w-32">
<td class="w-32 hidden md:table-cell">
<div class="join w-full">
<span class="join-item btn btn-sm btn-disabled">$</span>
<input
@@ -327,7 +327,7 @@
</td>
<!-- Discount -->
<td class="w-40">
<td class="w-40 hidden lg:table-cell">
<div class="flex gap-1">
<input
type="number"
@@ -350,7 +350,7 @@
</td>
<!-- Notes -->
<td class="w-48">
<td class="w-48 hidden xl:table-cell">
<textarea
:name="'items[' + index + '][notes]'"
class="textarea textarea-bordered textarea-sm w-full h-16 text-xs"
@@ -384,43 +384,46 @@
</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right font-semibold">Subtotal:</td>
<td colspan="2" class="text-right font-semibold sm:hidden">Subtotal:</td>
<td colspan="6" class="text-right font-semibold hidden sm:table-cell">Subtotal:</td>
<td colspan="2">
<span class="font-semibold" x-text="'$' + calculateSubtotal().toFixed(2)"></span>
</td>
</tr>
<tr x-show="calculateTotalLineDiscounts() > 0">
<td colspan="6" class="text-right font-semibold text-success">Line Discounts:</td>
<td colspan="2" class="text-right font-semibold text-success sm:hidden">Discounts:</td>
<td colspan="6" class="text-right font-semibold text-success hidden sm:table-cell">Line Discounts:</td>
<td colspan="2">
<span class="text-success" x-text="'-$' + calculateTotalLineDiscounts().toFixed(2)"></span>
</td>
</tr>
<tr x-show="calculateTotalLineDiscounts() > 0">
<tr x-show="calculateTotalLineDiscounts() > 0" class="hidden md:table-row">
<td colspan="6" class="text-right font-semibold">Subtotal After Line Discounts:</td>
<td colspan="2">
<span class="font-semibold" x-text="'$' + calculateSubtotalAfterLineDiscounts().toFixed(2)"></span>
</td>
</tr>
<tr x-show="calculateInvoiceDiscount() > 0">
<tr x-show="calculateInvoiceDiscount() > 0" class="hidden md:table-row">
<td colspan="6" class="text-right font-semibold text-success">Invoice Discount:</td>
<td colspan="2">
<span class="text-success" x-text="'-$' + calculateInvoiceDiscount().toFixed(2)"></span>
</td>
</tr>
<tr x-show="calculateInvoiceDiscount() > 0 || calculateTotalLineDiscounts() > 0">
<tr x-show="calculateInvoiceDiscount() > 0 || calculateTotalLineDiscounts() > 0" class="hidden md:table-row">
<td colspan="6" class="text-right font-semibold">Subtotal After All Discounts:</td>
<td colspan="2">
<span class="font-semibold" x-text="'$' + calculateSubtotalAfterAllDiscounts().toFixed(2)"></span>
</td>
</tr>
<tr>
<tr class="hidden md:table-row">
<td colspan="6" class="text-right font-semibold">Tax:</td>
<td colspan="2">
<span class="text-base-content/60" x-text="'$' + calculateTax().toFixed(2)"></span>
</td>
</tr>
<tr class="font-bold text-lg">
<td colspan="6" class="text-right">Grand Total:</td>
<td colspan="2" class="text-right sm:hidden">Total:</td>
<td colspan="6" class="text-right hidden sm:table-cell">Grand Total:</td>
<td colspan="2">
<span class="text-primary" x-text="'$' + calculateTotal().toFixed(2)"></span>
</td>
@@ -496,15 +499,15 @@
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-2">
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost">
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost btn-sm sm:btn-md">
Cancel
</a>
<button
type="submit"
class="btn btn-success"
class="btn btn-success btn-sm sm:btn-md"
:disabled="lineItems.length === 0 || !selectedCustomer || !paymentTerms || !dueDate">
<span class="icon-[heroicons--document-plus] size-5"></span>
<span class="icon-[heroicons--document-plus] size-4 sm:size-5"></span>
Create Invoice
</button>
</div>

View File

@@ -3,12 +3,12 @@
@section('content')
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
{{-- Page Header --}}
<header class="flex items-center justify-between">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold">Invoices</h1>
<p class="text-sm text-base-content/60">Manage and track your invoices.</p>
</div>
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-primary btn-sm gap-1">
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-secondary btn-sm gap-1 self-start sm:self-auto">
<span class="icon-[heroicons--plus] size-4"></span>
Create Invoice
</a>
@@ -18,6 +18,7 @@
<x-ui.filter-bar
form-action="{{ route('seller.business.invoices.index', $business->slug) }}"
search-placeholder="Search invoices..."
search-name="search"
:search-value="request('search')"
clear-url="{{ route('seller.business.invoices.index', $business->slug) }}"
>
@@ -42,7 +43,7 @@
<p class="text-xs text-base-content/50 mb-4 max-w-md">
Create your first invoice to bill customers and track payment status.
</p>
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-primary btn-sm">
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-secondary btn-sm">
Create Invoice
</a>
</div>
@@ -54,9 +55,9 @@
<thead class="bg-base-200/30">
<tr>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Invoice</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Customer</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Invoice Date</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Due Date</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden sm:table-cell">Customer</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden md:table-cell">Invoice Date</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden lg:table-cell">Due Date</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3">Amount</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Status</th>
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3"></th>
@@ -70,7 +71,7 @@
{{ $invoice->invoice_number }}
</a>
</td>
<td class="py-2 align-middle">
<td class="py-2 align-middle hidden sm:table-cell">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-base-200/50 text-base-content/50 flex items-center justify-center text-sm font-medium">
{{ strtoupper(mb_substr($invoice->business->name ?? 'C', 0, 1)) }}
@@ -89,10 +90,10 @@
</div>
</div>
</td>
<td class="py-2 align-middle whitespace-nowrap text-sm">
<td class="py-2 align-middle whitespace-nowrap text-sm hidden md:table-cell">
{{ $invoice->invoice_date->format('M j, Y') }}
</td>
<td class="py-2 align-middle whitespace-nowrap text-sm">
<td class="py-2 align-middle whitespace-nowrap text-sm hidden lg:table-cell">
@if($invoice->due_date)
<span class="{{ $invoice->isOverdue() ? 'text-error font-medium' : '' }}">
{{ $invoice->due_date->format('M j, Y') }}
@@ -106,11 +107,11 @@
</td>
<td class="py-2 align-middle">
@if($invoice->payment_status === 'paid')
<span class="badge badge-success badge-sm">Paid</span>
<span class="badge badge-ghost badge-sm">Paid</span>
@elseif($invoice->isOverdue())
<span class="badge badge-error badge-sm">Overdue</span>
<span class="badge badge-ghost badge-sm">Overdue</span>
@else
<span class="badge badge-neutral badge-sm">Unpaid</span>
<span class="badge badge-ghost badge-sm">Unpaid</span>
@endif
</td>
<td class="py-2 align-middle text-right">

View File

@@ -117,12 +117,12 @@
<td class="text-right">
@if($period->isOpen() && $canClosePeriods)
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<button tabindex="0" class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--lock-closed] size-4"></span>
Close
<span class="icon-[heroicons--chevron-down] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-48">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<button type="button" onclick="openCloseModal({{ $period->id }}, '{{ $period->period_label }}', 'soft_closed')">
<span class="icon-[heroicons--lock-open] size-4 text-warning"></span>

View File

@@ -133,10 +133,10 @@
</td>
<td>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-5"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-10">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<button type="button" @click="openEditModal({{ json_encode($vendor) }})">
<span class="icon-[heroicons--pencil] size-4"></span>

View File

@@ -107,12 +107,12 @@
<span class="badge badge-ghost badge-sm">Inactive</span>
@endif
</td>
<td class="text-right">
<td class="py-2 align-middle text-right">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-48">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.management.bank-accounts.show', [$business, $account]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -135,10 +135,10 @@
<td class="text-sm text-base-content/60">{{ $tx->category_display }}</td>
<td>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<button @click="openMatchModal({{ $tx->id }})">
<span class="icon-[heroicons--link] size-4"></span>

View File

@@ -80,10 +80,10 @@
Edit
</a>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
@if(!$budget->approved_at)
<li>
<form action="{{ route('seller.business.management.budgets.approve', [$business, $budget]) }}" method="POST">

View File

@@ -116,10 +116,10 @@
View
</a>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.management.recurring.edit', [$business, $schedule]) }}">Edit</a>
</li>

View File

@@ -198,12 +198,10 @@
{{-- Actions Dropdown --}}
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"></path>
</svg>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-10">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li><a href="{{ route('seller.marketing.broadcasts.show', $broadcast) }}">View Details</a></li>
<li><a href="{{ route('seller.marketing.broadcasts.analytics', $broadcast) }}">Analytics</a></li>

View File

@@ -136,12 +136,12 @@
<span class="text-base-content/50 text-sm">-</span>
@endif
</td>
<td class="py-2 text-right">
<td class="py-2 align-middle text-right">
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-44 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.marketing.campaigns.show', [$business->slug, $campaign]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -82,10 +82,10 @@
<span class="badge badge-neutral badge-sm">Inactive</span>
@endif
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.marketing.channels.edit', [$business->slug, $channel]) }}">
<span class="icon-[heroicons--pencil] size-4"></span>

View File

@@ -81,11 +81,11 @@
@change="selected = $event.target.checked ? {{ $contacts->pluck('id') }} : []" />
</th>
<th>Contact</th>
<th>Type</th>
<th>Email</th>
<th>Phone</th>
<th>Subscribed</th>
<th>Tags</th>
<th class="hidden sm:table-cell">Type</th>
<th class="hidden md:table-cell">Email</th>
<th class="hidden lg:table-cell">Phone</th>
<th class="hidden md:table-cell">Subscribed</th>
<th class="hidden lg:table-cell">Tags</th>
<th></th>
</tr>
</thead>
@@ -100,14 +100,14 @@
<div class="font-medium">{{ $contact->display_name }}</div>
<div class="text-xs text-base-content/60">{{ $contact->source }}</div>
</td>
<td>
<td class="hidden sm:table-cell">
<span class="badge badge-sm {{ $contact->type === 'buyer' ? 'badge-primary' : ($contact->type === 'consumer' ? 'badge-secondary' : 'badge-ghost') }}">
{{ $contact->getTypeLabel() }}
</span>
</td>
<td class="text-sm">{{ $contact->email ?? '-' }}</td>
<td class="text-sm">{{ $contact->phone ?? '-' }}</td>
<td>
<td class="text-sm hidden md:table-cell">{{ $contact->email ?? '-' }}</td>
<td class="text-sm hidden lg:table-cell">{{ $contact->phone ?? '-' }}</td>
<td class="hidden md:table-cell">
<div class="flex gap-1">
@if($contact->is_subscribed_email && $contact->email)
<span class="badge badge-xs badge-success">Email</span>
@@ -117,7 +117,7 @@
@endif
</div>
</td>
<td>
<td class="hidden lg:table-cell">
@if($contact->tags)
<div class="flex flex-wrap gap-1">
@foreach(array_slice($contact->tags, 0, 3) as $tag)
@@ -131,14 +131,12 @@
-
@endif
</td>
<td>
<td class="py-2 align-middle text-right">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" />
</svg>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li><a href="{{ route('seller.business.marketing.contacts.edit', [$business, $contact]) }}">Edit</a></li>
<li>
<form method="POST" action="{{ route('seller.business.marketing.contacts.destroy', [$business, $contact]) }}" onsubmit="return confirm('Delete this contact?')">

View File

@@ -29,12 +29,10 @@
</span>
</div>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" />
</svg>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li><a href="{{ route('seller.business.marketing.lists.show', [$business, $list]) }}">View Contacts</a></li>
<li><a href="{{ route('seller.business.marketing.lists.edit', [$business, $list]) }}">Edit</a></li>
<li>

View File

@@ -131,12 +131,12 @@
<span class="text-base-content/40">-</span>
@endif
</td>
<td class="py-2 text-right">
<td class="py-2 align-middle text-right">
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.marketing.promos.show', [$business->slug, $promo]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -120,12 +120,12 @@
<td class="py-2 text-sm whitespace-nowrap">
{{ $template->updated_at->diffForHumans() }}
</td>
<td class="py-2 text-right">
<td class="py-2 align-middle text-right">
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.marketing.templates.show', [$business->slug, $template]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -3,7 +3,7 @@
@section('content')
<div class="max-w-7xl mx-auto px-6 py-4 space-y-4">
{{-- Page Header --}}
<header class="flex items-center justify-between">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h1 class="text-2xl font-semibold">Orders</h1>
<p class="text-sm text-base-content/60">Manage and fulfill customer orders</p>
@@ -51,10 +51,10 @@
<tr>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Order</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Customer</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Date</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center">Items</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap hidden md:table-cell">Date</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center hidden lg:table-cell">Items</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-right">Total</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center">Fulfillment</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center hidden lg:table-cell">Fulfillment</th>
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Status</th>
<th class="text-xs font-medium text-base-content/50 py-3"></th>
</tr>
@@ -83,36 +83,36 @@
</div>
</div>
</td>
<td class="py-3 whitespace-nowrap">
<td class="py-3 whitespace-nowrap hidden md:table-cell">
<div class="text-sm">{{ $order->created_at->format('M j, Y') }}</div>
<div class="text-[11px] text-base-content/60">{{ $order->created_at->format('g:i A') }}</div>
</td>
<td class="py-3 text-center">
<td class="py-3 text-center hidden lg:table-cell">
<span class="text-sm text-base-content/80">{{ $order->items->count() }}</span>
</td>
<td class="py-3 text-right">
<span class="text-sm font-semibold">${{ number_format($order->total, 2) }}</span>
</td>
<td class="py-3 text-center">
<td class="py-3 text-center hidden lg:table-cell">
@if($order->workorder_status == 100)
<span class="badge badge-neutral badge-sm whitespace-nowrap">Complete</span>
<span class="badge badge-success badge-sm whitespace-nowrap">Complete</span>
@elseif($order->workorder_status == 0)
<span class="badge badge-neutral badge-sm whitespace-nowrap">Not Started</span>
<span class="badge badge-success badge-sm whitespace-nowrap">Not Started</span>
@elseif($order->workorder_status > 0 && $order->workorder_status < 100)
<span class="badge badge-neutral badge-sm whitespace-nowrap">{{ number_format($order->workorder_status, 0) }}%</span>
<span class="badge badge-success badge-sm whitespace-nowrap">{{ number_format($order->workorder_status, 0) }}%</span>
@else
<span class="badge badge-neutral badge-sm whitespace-nowrap">Not Started</span>
<span class="badge badge-success badge-sm whitespace-nowrap">Not Started</span>
@endif
</td>
<td class="py-3">
@include('buyer.orders.partials.status-badge', ['status' => $order->status])
</td>
<td class="py-3 text-right">
<td class="py-2 align-middle text-right">
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<button tabindex="0" role="button" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-lg shadow-sm w-48 p-2 z-[100] border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.orders.show', [$business->slug, $order]) }}">
<span class="icon-[heroicons--eye] size-4"></span>

View File

@@ -3,10 +3,10 @@
@section('content')
<div class="max-w-7xl mx-auto px-6 py-4 space-y-4"
x-data="{
// State
search: '',
brandFilter: 'all',
statusFilter: 'all',
// State - initialize from URL params to sync with server-side filtering
search: '{{ request('search', '') }}',
brandFilter: '{{ request('brand_id', 'all') ?: 'all' }}',
statusFilter: '{{ request('status', 'all') ?: 'all' }}',
visibilityFilter: 'all',
focusFilter: 'all',
viewMode: 'table',
@@ -88,7 +88,7 @@
}">
{{-- Page Header --}}
<div class="flex items-center justify-between">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h1 class="text-xl font-semibold text-base-content">Products</h1>
<p class="text-sm text-base-content/50">Manage your product catalog</p>
@@ -146,38 +146,38 @@
</x-ui.filter-bar>
{{-- Filter Chips Row --}}
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex items-center gap-1.5 overflow-x-auto pb-1">
<button @click="setFocusFilter('all')"
:class="focusFilter === 'all' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
class="btn btn-xs btn-ghost rounded-md px-2.5">
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
All
</button>
<button @click="setFocusFilter('low-stock')"
:class="focusFilter === 'low-stock' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
class="btn btn-xs btn-ghost rounded-md px-2.5">
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
Low Stock
</button>
<button @click="setFocusFilter('out-of-stock')"
:class="focusFilter === 'out-of-stock' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
class="btn btn-xs btn-ghost rounded-md px-2.5">
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
Out of Stock
</button>
<button @click="setFocusFilter('missing-images')"
:class="focusFilter === 'missing-images' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
class="btn btn-xs btn-ghost rounded-md px-2.5">
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0 hidden sm:inline-flex">
Missing Images
</button>
<button @click="setFocusFilter('not-listed')"
:class="focusFilter === 'not-listed' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
class="btn btn-xs btn-ghost rounded-md px-2.5">
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0 hidden sm:inline-flex">
Not Listed
</button>
<button @click="setFocusFilter('drafts')"
:class="focusFilter === 'drafts' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
class="btn btn-xs btn-ghost rounded-md px-2.5">
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
Drafts
</button>
<span class="text-xs text-base-content/40 ml-2">
<span class="text-xs text-base-content/40 ml-2 whitespace-nowrap flex-shrink-0">
<span x-text="filteredListings.length"></span>/<span x-text="pagination.total"></span>
</span>
</div>
@@ -189,11 +189,11 @@
<thead class="bg-base-200/30">
<tr>
<th class="text-xs font-medium text-base-content/50 py-2.5">Product</th>
<th class="text-xs font-medium text-base-content/50 py-2.5">Brand</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 hidden md:table-cell">Brand</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right">Price</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right">Stock</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right">Conv.</th>
<th class="text-xs font-medium text-base-content/50 py-2.5">Status</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right hidden lg:table-cell">Stock</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right hidden xl:table-cell">Conv.</th>
<th class="text-xs font-medium text-base-content/50 py-2.5 hidden sm:table-cell">Status</th>
<th class="text-xs font-medium text-base-content/50 py-2.5"></th>
</tr>
</thead>
@@ -227,28 +227,38 @@
</div>
</div>
</td>
<td class="py-2.5"><span class="text-sm text-base-content/70" x-text="listing.brand"></span></td>
<td class="py-2.5 hidden md:table-cell"><span class="text-sm text-base-content/70" x-text="listing.brand"></span></td>
<td class="py-2.5 text-right font-medium text-sm">$<span x-text="listing.price.toFixed(2)"></span></td>
<td class="py-2.5 text-right">
<td class="py-2.5 text-right hidden lg:table-cell">
<span class="text-sm" x-text="listing.stock"></span>
<span x-show="listing.stock === 0" class="text-xs ml-1 text-error">(Out)</span>
</td>
<td class="py-2.5 text-right">
<td class="py-2.5 text-right hidden xl:table-cell">
<span class="text-sm text-base-content/70" x-text="getConversion(listing) + '%'"></span>
</td>
<td class="py-2.5">
<td class="py-2.5 hidden sm:table-cell">
<span x-show="listing.status === 'active'" class="badge badge-neutral badge-sm">Active</span>
<span x-show="listing.status === 'paused'" class="badge badge-neutral badge-sm">Paused</span>
<span x-show="listing.status === 'draft'" class="badge badge-neutral badge-sm">Draft</span>
</td>
<td class="py-2.5" @click.stop>
<td class="py-2 align-middle text-right" @click.stop>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<span class="icon-[heroicons--ellipsis-vertical] size-4 text-base-content/40"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-1.5 bg-base-100 rounded-lg w-40 border border-base-300">
<li><a @click="openInspector(listing)" class="text-sm py-1.5"><span class="icon-[heroicons--information-circle] size-4"></span> Quick View</a></li>
<li><a :href="listing.edit_url" class="text-sm py-1.5"><span class="icon-[heroicons--pencil] size-4"></span> Edit</a></li>
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a @click="openInspector(listing)">
<span class="icon-[heroicons--information-circle] size-4"></span>
Quick View
</a>
</li>
<li>
<a :href="listing.edit_url">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
</li>
</ul>
</div>
</td>

View File

@@ -200,10 +200,10 @@
{{-- Standard actions dropdown --}}
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[lucide--more-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-48 p-2 shadow-lg border border-base-200">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->hashid]) }}">
<span class="icon-[lucide--eye] size-4"></span>

View File

@@ -501,14 +501,24 @@
<span x-show="listing.status === 'paused'" class="badge badge-warning badge-xs">Paused</span>
<span x-show="listing.status === 'draft'" class="badge badge-ghost badge-xs">Draft</span>
</td>
<td @click.stop>
<td class="py-2 align-middle text-right" @click.stop>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
<li><a @click="openInspector(listing)"><span class="icon-[heroicons--information-circle] size-4"></span> Quick View</a></li>
<li><a :href="listing.edit_url"><span class="icon-[heroicons--pencil] size-4"></span> Edit Product</a></li>
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li>
<a @click="openInspector(listing)">
<span class="icon-[heroicons--information-circle] size-4"></span>
Quick View
</a>
</li>
<li>
<a :href="listing.edit_url">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit Product
</a>
</li>
</ul>
</div>
</td>

View File

@@ -130,12 +130,12 @@
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm" {{ $webhook['enabled'] ? 'checked' : '' }} onchange="toggleWebhook({{ $webhook['id'] }})" />
</td>
<td>
<td class="py-2 align-middle text-right">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu menu-sm p-2 shadow-lg bg-base-100 rounded-box w-48 border border-base-300">
</button>
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
<li><a class="gap-2"><span class="icon-[heroicons--pencil] size-4"></span> Edit</a></li>
<li><a class="gap-2"><span class="icon-[heroicons--paper-airplane] size-4"></span> Test Webhook</a></li>
<li><a class="gap-2"><span class="icon-[heroicons--arrow-path] size-4"></span> View History</a></li>

View File

@@ -444,6 +444,9 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::get('/{brand}/dashboard', [\App\Http\Controllers\Seller\BrandController::class, 'dashboard'])->name('dashboard');
Route::get('/{brand}/dashboard/tab-data', [\App\Http\Controllers\Seller\BrandController::class, 'tabData'])->name('dashboard.tab-data');
Route::get('/{brand}/stats', [\App\Http\Controllers\Seller\BrandController::class, 'stats'])->name('stats');
Route::get('/{brand}/analysis', [\App\Http\Controllers\Seller\BrandController::class, 'analysis'])->name('analysis');
Route::post('/{brand}/analysis/refresh', [\App\Http\Controllers\Seller\BrandController::class, 'analysisRefresh'])->name('analysis.refresh');
Route::get('/{brand}/store-playbook/{store}', [\App\Http\Controllers\Seller\BrandController::class, 'storePlaybook'])->name('analysis.storePlaybook');
Route::get('/{brand}/profile', [\App\Http\Controllers\Seller\BrandController::class, 'profile'])->name('profile');
Route::get('/{brand}/edit', [\App\Http\Controllers\Seller\BrandController::class, 'edit'])->name('edit');
Route::get('/{brand}/edit-nexus', [\App\Http\Controllers\Seller\BrandController::class, 'editNexus'])->name('edit-nexus');

View File

@@ -29,13 +29,13 @@ export default {
themes: [
{
cannabrands: {
"primary": "#4B6FA4",
"primary": "#4E8D71",
"primary-content": "#ffffff",
"secondary": "#6B7280",
"secondary": "#4B6FA4",
"secondary-content": "#ffffff",
"accent": "#4B6FA4",
"accent": "#4E8D71",
"accent-content": "#ffffff",
"success": "#4E8D71",

View File

@@ -171,6 +171,14 @@ it('brand manager scoped to single brand only sees their brand', function () {
// ─────────────────────────────────────────────────────────────────────────────
it('product index respects brand context filter', function () {
$this->withoutExceptionHandling();
\DB::listen(function ($query) {
if (str_contains($query->sql, 'ERROR')) {
dump($query->sql);
}
});
// Create products for each brand
$product1 = Product::factory()->create([
'brand_id' => $this->brand1->id,