Compare commits

...

25 Commits

Author SHA1 Message Date
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
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
61 changed files with 7730 additions and 208 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,121 @@ 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]);
// 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]);
$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
// 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
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
// Include locations for delivery address selection
$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',

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,333 @@
<?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,
// 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,
'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

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

@@ -126,6 +126,13 @@
.collapse {
input {
@apply min-h-8 p-0;
/* Only cover the collapse-title, not the content */
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rem; /* Match min-h-8 */
z-index: 1;
}
.collapse-title {
@@ -247,15 +254,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 +309,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)">
@@ -693,6 +684,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>

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

@@ -19,10 +19,10 @@
</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>

View File

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

@@ -132,12 +132,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

@@ -8,7 +8,7 @@
<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">
<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>
@@ -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

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

@@ -95,24 +95,24 @@
</td>
<td class="py-3 text-center">
@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',
@@ -241,14 +241,24 @@
<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,