feat: migrate product edit page to new top header layout

- Replace sidebar layout with top header design
- Add product image thumbnail, badges (Active/Featured), and action buttons
- Implement real-time badge toggling with inline JavaScript
- Add one-active-product-per-brand validation with force-activate option
- Standardize checkbox styling with DaisyUI components
- Update terminology from "Default" to "Primary" for images
- Add new models: ProductLine, ProductPackaging, Unit
- Add product line management and image sorting
- Add styling rules to CLAUDE.md for consistency

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-11-05 23:41:37 -07:00
parent ded374de3c
commit 32fd2b0ab8
56 changed files with 7305 additions and 729 deletions

9
.blade-formatter.json Normal file
View File

@@ -0,0 +1,9 @@
{
"indentSize": 4,
"wrapAttributes": "auto",
"wrapLineLength": 120,
"endWithNewLine": true,
"useTabs": false,
"sortTailwindcssClasses": true,
"sortHtmlAttributes": "none"
}

View File

@@ -0,0 +1,35 @@
# Number Input Spinners Removed
## Summary
All number input spinner arrows (up/down buttons) have been globally removed from the application.
## Implementation
CSS has been added to both main layout files to hide spinners:
1. **app.blade.php** (lines 17-31)
2. **app-with-sidebar.blade.php** (lines 17-31)
## CSS Used
```css
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
```
## User Preference
User specifically requested:
- Remove up/down arrows on number input boxes
- Apply this globally across all pages
- Remember this preference for future pages
## Date
2025-11-05

View File

@@ -15,7 +15,11 @@
"Bash(php -l:*)",
"Bash(curl:*)",
"Bash(cat:*)",
"Bash(docker update:*)"
"Bash(docker update:*)",
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
],
"deny": [],
"ask": []

View File

@@ -77,10 +77,25 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# AWS/MinIO S3 Storage Configuration
# Local development: Use FILESYSTEM_DISK=public (default)
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_ENDPOINT=
AWS_URL=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production MinIO Configuration (example):
# FILESYSTEM_DISK=s3
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media
# AWS_USE_PATH_STYLE_ENDPOINT=true
VITE_APP_NAME="${APP_NAME}"

1
.gitignore vendored
View File

@@ -58,3 +58,4 @@ core.*
!resources/**/*.png
!resources/**/*.jpg
!resources/**/*.jpeg
.claude/settings.local.json

20
.stylelintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "stylelint-config-standard",
"plugins": [
"stylelint-no-unsupported-browser-features"
],
"rules": {
"no-descending-specificity": null,
"selector-class-pattern": null,
"custom-property-pattern": null,
"declaration-block-no-duplicate-properties": true,
"no-duplicate-selectors": true
},
"ignoreFiles": [
"**/*.js",
"**/*.php",
"node_modules/**",
"vendor/**",
"public/**"
]
}

View File

@@ -35,6 +35,19 @@ ALL routes need auth + user type middleware except public pages
❌ No IF/ELSE logic in migrations (not supported)
✅ Use Laravel Schema builder or conditional PHP code
### 7. Styling - DaisyUI/Tailwind Only
**NEVER use inline `style=""` attributes** in Blade templates
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
**Why:** Consistency, maintainability, theme switching, and better performance
**Correct patterns:**
- Colors: Use `bg-primary`, `text-primary`, `bg-success`, etc. (defined in `resources/css/app.css`)
- Spacing: Use `p-4`, `m-2`, `gap-3` (Tailwind utilities)
- Layout: Use `flex`, `grid`, `items-center` (Tailwind utilities)
- Custom colors: Add to `resources/css/app.css` theme variables, NOT inline
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
---
## Tech Stack by Area
@@ -106,4 +119,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
✅ Use Eloquent (never raw SQL)
✅ Protect routes with middleware
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing

258
PRODUCT2_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,258 @@
# PRODUCT2 MIGRATION INSTRUCTIONS
## Context
We are migrating the OLD seller product page from `../cannabrands-hub-old` to create a new "Product2" page in the current project at `/hub`. This page will be a comprehensive, modernized version of the old seller product edit page.
## Critical Rules
1. **SELLER SIDE ONLY** - Work only with `/s/` routes (seller area)
2. **STAY IN BRANCH** - `feature/product-page-migrate` (verify before making changes)
3. **ROLLBACK READY** - All database migrations must be fully reversible
4. **DO NOT TOUCH BOM** - Leave existing BOM functionality completely as-is (we'll discuss later)
5. **SINGLE PAGE LAYOUT** - No tabs, use card-based layout with Nexus components
6. **FOLLOW OLD LAYOUT** - Modernize the old product page structure, don't reinvent
## Old Project Analysis Complete
- Old project location: `../cannabrands-hub-old`
- Old used Laravel CRM for product management
- Comprehensive field analysis done (see below)
- Old layout analyzed from vendor views
## Complete Missing Fields (from migrations analysis)
### From `products` table:
```sql
-- Metadata
product_line (text, nullable)
product_link (text, nullable) -- External URL
creatives (text, nullable) -- Marketing assets
barcode (string, nullable)
brand_display_order (integer, nullable)
-- Configuration
has_varieties (boolean, default: false)
license_id (unsignedBigInteger, nullable)
sell_multiples (boolean, default: false)
fractional_quantities (boolean, default: false)
allow_sample (boolean, default: false)
isFPR (boolean, default: false)
isSellable (boolean, default: false)
-- Case/Box Packaging
isCase (boolean, default: false)
cased_qty (integer, default: 0)
isBox (boolean, default: false)
boxed_qty (integer, default: 0)
-- Dates
launch_date (date, nullable)
-- Inventory Management
inventory_manage_pct (integer, nullable) -- 0-100%
min_order_qty (integer, nullable)
max_order_qty (integer, nullable)
low_stock_threshold (integer, nullable)
low_stock_alert_enabled (boolean, default: false)
-- Strain
strain_value (decimal 8,2, nullable)
-- Arizona Compliance
arz_total_weight (decimal 10,3, nullable)
arz_usable_mmj (decimal 10,3, nullable)
-- Descriptions
long_description (text, nullable)
ingredients (text, nullable)
effects (text, nullable)
dosage_guidelines (text, nullable)
-- Visibility
show_inventory_to_buyers (boolean, default: false)
-- Threshold Automation
decreasing_qty_threshold (integer, nullable)
decreasing_qty_action (string, nullable)
increasing_qty_threshold (integer, nullable)
increasing_qty_action (string, nullable)
-- Packaging Reference
packaging_id (foreignId, nullable)
-- Enhanced Status
status (enum: available, archived, sample, backorder, internal, unavailable)
```
### Need to create:
- `product_packaging` table (id, name, description, is_active, timestamps)
## Product2 Page Layout (Single Page, No Tabs)
### Structure:
```
HEADER (Product name, SKU, status badges, action buttons)
LEFT SIDEBAR (1/3 width):
- Product Images (main + gallery + upload)
- Quick Stats Card (cost, wholesale, MSRP, margin)
- Audit Info Card (created, modified, by user)
MAIN CONTENT (2/3 width):
Card 1: Basic Information
Card 2: Pricing & Units
Card 3: Inventory Management
Card 4: Cannabis Information
Card 5: Product Details & Content
Card 6: Advanced Settings
Card 7: Compliance & Tracking
FULL WIDTH (bottom):
Card 8: Product Varieties (if has_varieties = true)
Card 9: Lab Test Results (link to separate management)
Collapsible: Audit History
```
### Cards Detail:
**Card 1: Basic Information**
- Brand (dropdown) *
- Product Line (text)
- SKU (text) *
- Barcode (text)
- Product Name (text) *
- Type (dropdown) *
- Category (text)
- Description (textarea)
- Active toggle
- Featured toggle
**Card 2: Pricing & Units**
- Cost Price, Wholesale, MSRP, Margin (auto-calc)
- Price Unit dropdown
- Net Weight + Weight Unit
- Units Per Case
- Checkboxes: Sell in Multiples, Fractional Quantities, Sell as Case, Sell as Box
**Card 3: Inventory Management**
- On Hand, Allocated, Available, Reorder Point (display)
- Min/Max Order Qty
- Low Stock Threshold + Alert checkbox
- Show Inventory to Buyers checkbox
- Inventory Management slider (0-100%)
- Threshold Automation (decrease/increase triggers)
**Card 4: Cannabis Information**
- THC%, CBD%, THC mg, CBD mg
- Strain dropdown (with classification)
- Strain Value
- Product Packaging dropdown
- Ingredients, Effects, Dosing Guidelines (text areas)
- Arizona Compliance (Total Weight, Usable MMJ)
**Card 5: Product Details & Content**
- Short Description
- Long Description (rich text editor)
- Product Link (external URL)
- Creatives/Assets
**Card 6: Advanced Settings**
- Enable Sample Requests checkbox
- Sellable Product checkbox
- Finished Product Ready checkbox
- Status dropdown
- Display Order (within brand)
**Card 7: Compliance & Tracking**
- Metrc ID
- License dropdown
- Launch Date, Harvest Date, Package Date, Test Date
**Card 8: Product Varieties** (conditional)
- Table showing child products with name, SKU, prices, stock
- Add Variety button
**Card 9: Lab Test Results**
- Summary of latest lab test
- Link to full lab management (don't build lab CRUD yet)
## Tasks to Complete
### 1. Database Migration (with rollback)
- Create migration: `add_product2_fields_to_products_table.php`
- Add ALL missing fields listed above
- Proper indexes
- Full `down()` method for rollback
- Create `product_packaging` table migration
### 2. Routes
- File: `routes/seller.php`
- Add under existing products routes:
- `/{product}/edit2` → Product2 edit page
- Keep existing routes intact
### 3. Controller
- Create: `app/Http/Controllers/Seller/Product2Controller.php`
- Methods: edit(), update()
- Full validation for all new fields
- Business isolation checks (CRITICAL - see CLAUDE.md)
- Image upload handling
### 4. Model Updates
- Update `app/Models/Product.php` fillable array
- Add new relationships if needed (packaging)
- Add accessors/mutators as needed
### 5. Views
- Create: `resources/views/seller/products/edit2.blade.php`
- Use Nexus card components
- Single page layout (no tabs)
- Alpine.js for interactivity
- Follow structure outlined above
- Use existing DaisyUI + Nexus patterns
### 6. Nexus Components Available
From `nexus-html@3.1.0/resources/views/`:
- Cards: `card`, `card-body`, `card-title`
- Forms: `input`, `select`, `textarea`, `checkbox`, `toggle`, `label`, `fieldset`
- Layouts: Grid system with responsive columns
- File upload: FilePond integration
- Date picker: Flatpickr
- Icons: Iconify (lucide set)
## Key Files from Old Project
- Controller: `vendor/venturedrake/laravel-crm/src/Http/Controllers/ProductController.php`
- Edit View: `vendor/venturedrake/laravel-crm/resources/views/products/edit.blade.php`
- Fields Form: `vendor/venturedrake/laravel-crm/resources/views/products/partials/fields.blade.php` (1400+ lines!)
## Current Project Files
- Routes: `routes/seller.php`
- Controller: `app/Http/Controllers/Seller/ProductController.php`
- Model: `app/Models/Product.php`
- Current Edit: `resources/views/seller/products/edit.blade.php`
- Migration: `database/migrations/2025_10_07_172951_create_products_table.php`
## Important Notes from CLAUDE.md
1. **Business Isolation**: ALWAYS scope by business_id BEFORE finding by ID
- `Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->findOrFail($id)`
2. **Route Protection**: Use middleware `['auth', 'verified', 'seller', 'approved']`
3. **No Filament**: Use DaisyUI + Blade for seller area
4. **Run tests before commit**: `php artisan test --parallel && ./vendor/bin/pint`
## Git Branch
- Current: `feature/product-page-migrate`
- DO NOT commit to develop directly
## Next Steps
1. Verify branch: `git branch` (should show feature/product-page-migrate)
2. Create migrations with full rollback capability
3. Update Product model
4. Create Product2Controller
5. Create edit2.blade.php view
6. Test thoroughly
7. Run Pint + tests
8. Commit with clear message
## Questions to Clarify Before Building
- Collapsible cards to reduce clutter? (yes/no)
- Should quantity_on_hand be editable in UI? (currently hidden)
- Which fields are absolutely required vs nice-to-have?
- SQL dump ready for real data analysis?

View File

@@ -6,6 +6,10 @@ use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\ProductLine;
use App\Models\ProductPackaging;
use App\Models\Strain;
use App\Models\Unit;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -69,7 +73,13 @@ class ProductController extends Controller
// Get all brands for filter dropdown
$brands = $business->brands()->orderBy('name')->get();
return view('seller.products.index', compact('business', 'products', 'brands'));
// Get product lines for this business with products count
$productLines = ProductLine::where('business_id', $business->id)
->withCount('products')
->orderBy('name')
->get();
return view('seller.products.index', compact('business', 'products', 'brands', 'productLines'));
}
/**
@@ -148,23 +158,101 @@ class ProductController extends Controller
*/
public function edit(Business $business, Product $product)
{
// Eager load relationships
$product->load(['brand', 'images']);
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
->findOrFail($product->id);
// Verify product belongs to this business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business');
}
// Prepare dropdown data
$brands = Brand::where('business_id', $business->id)->get();
$strains = Strain::all();
$packagings = ProductPackaging::all();
$units = Unit::all();
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
$brands = $business->brands()->orderBy('name')->get();
// Product type options (for category dropdown)
$productTypes = [
'flower' => 'Flower',
'preroll' => 'Pre-Roll',
'vape' => 'Vape',
'concentrate' => 'Concentrate',
'edible' => 'Edible',
'topical' => 'Topical',
'tincture' => 'Tincture',
'other' => 'Other',
];
// Load audits with pagination (10 per page) for the audit history tab
$audits = $product->audits()
->with('user')
->latest()
->paginate(10);
// Status options
$statusOptions = [
'active' => 'Active',
'inactive' => 'Inactive',
'discontinued' => 'Discontinued',
];
return view('seller.products.edit', compact('business', 'product', 'brands', 'audits'));
return view('seller.products.edit', compact(
'business',
'product',
'brands',
'strains',
'packagings',
'units',
'productLines',
'productTypes',
'statusOptions'
));
}
/**
* Show the form for editing the specified product (edit1 - top header layout)
*/
public function edit1(Business $business, Product $product)
{
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
->findOrFail($product->id);
// Prepare dropdown data
$brands = Brand::where('business_id', $business->id)->get();
$strains = Strain::all();
$packagings = ProductPackaging::all();
$units = Unit::all();
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
// Product type options (for category dropdown)
$productTypes = [
'flower' => 'Flower',
'preroll' => 'Pre-Roll',
'vape' => 'Vape',
'concentrate' => 'Concentrate',
'edible' => 'Edible',
'topical' => 'Topical',
'tincture' => 'Tincture',
'other' => 'Other',
];
// Status options
$statusOptions = [
'active' => 'Active',
'inactive' => 'Inactive',
'discontinued' => 'Discontinued',
];
return view('seller.products.edit1', compact(
'business',
'product',
'brands',
'strains',
'packagings',
'units',
'productLines',
'productTypes',
'statusOptions'
));
}
/**
@@ -172,58 +260,150 @@ class ProductController extends Controller
*/
public function update(Request $request, Business $business, Product $product)
{
// Verify product belongs to this business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business');
}
// Comprehensive validation
$validated = $request->validate([
// Basic Information
'brand_id' => 'required|exists:brands,id',
'name' => 'required|string|max:255',
'sku' => 'required|string|max:100|unique:products,sku,'.$product->id,
'description' => 'nullable|string',
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
'category' => 'nullable|string|max:100',
'wholesale_price' => 'required|numeric|min:0',
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
'sku' => 'required|string|max:100',
'barcode' => 'nullable|string|max:100',
'type' => 'nullable|string',
'product_line_id' => 'nullable|exists:product_lines,id',
'unit_id' => 'required|exists:units,id',
'sell_multiples' => 'nullable|boolean',
'fractional_quantities' => 'nullable|boolean',
'allow_sample' => 'nullable|boolean',
'is_active' => 'nullable|boolean',
'is_featured' => 'nullable|boolean',
// Inventory - now includes threshold type
'status' => 'required|string',
'launch_date' => 'nullable|date',
'quantity_on_hand' => 'nullable|integer|min:0',
'quantity_allocated' => 'nullable|integer|min:0',
'sync_bamboo' => 'nullable|boolean',
'low_stock_threshold' => 'nullable|numeric|min:0',
'low_stock_threshold_type' => 'nullable|string|in:qty,percent',
'low_stock_alert_enabled' => 'nullable|boolean',
'is_assembly' => 'nullable|boolean',
'show_inventory_to_buyers' => 'nullable|boolean',
'packaging_id' => 'nullable|exists:product_packagings,id',
// Pricing & Units
'cost_per_unit' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
'msrp' => 'nullable|numeric|min:0',
'net_weight' => 'nullable|numeric|min:0',
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
'units_per_case' => 'nullable|integer|min:1',
'weight_unit' => 'nullable|string|max:20',
'units_per_case' => 'nullable|integer|min:0',
'cased_qty' => 'nullable|integer|min:0',
'boxed_qty' => 'nullable|integer|min:0',
'min_order_qty' => 'nullable|integer|min:0',
'max_order_qty' => 'nullable|integer|min:0',
'is_case' => 'nullable|boolean',
'is_box' => 'nullable|boolean',
'has_varieties' => 'nullable|boolean',
// Cannabis Information
'thc_percentage' => 'nullable|numeric|min:0|max:100',
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'strain_id' => 'nullable|exists:strains,id',
'thc_content_mg' => 'nullable|numeric|min:0',
'cbd_content_mg' => 'nullable|numeric|min:0',
'strain_value' => 'nullable|numeric|min:0',
'ingredients' => 'nullable|string',
'effects' => 'nullable|string',
'dosage_guidelines' => 'nullable|string',
// Arizona Compliance
'arz_total_weight' => 'nullable|numeric|min:0',
'arz_usable_mmj' => 'nullable|numeric|min:0',
'metrc_id' => 'nullable|string|max:255',
// Compliance & Tracking
'license_number' => 'nullable|string|max:255',
'harvest_date' => 'nullable|date',
'package_date' => 'nullable|date',
'test_date' => 'nullable|date',
// Product Details
'description' => 'nullable|string|max:100',
'long_description' => 'nullable|string',
'product_link' => 'nullable|url|max:255',
'creatives_json' => 'nullable|json',
// Advanced Settings
'is_sellable' => 'nullable|boolean',
'is_fpr' => 'nullable|boolean',
'is_raw_material' => 'nullable|boolean',
'brand_display_order' => 'nullable|integer|min:0',
'parent_product_id' => 'nullable|exists:products,id',
'category' => 'nullable|string|max:100',
]);
// Verify new brand belongs to this business
$brand = Brand::forBusiness($business)
->findOrFail($validated['brand_id']);
// Update slug if name changed
if ($validated['name'] !== $product->name) {
$validated['slug'] = Str::slug($validated['name']);
}
// Handle checkbox fields - set to false if not present in request
// Convert checkboxes to boolean
$validated['is_active'] = $request->has('is_active');
$validated['is_featured'] = $request->has('is_featured');
$validated['sell_multiples'] = $request->has('sell_multiples');
$validated['fractional_quantities'] = $request->has('fractional_quantities');
$validated['allow_sample'] = $request->has('allow_sample');
$validated['is_case'] = $request->has('is_case');
$validated['is_box'] = $request->has('is_box');
$validated['has_varieties'] = $request->has('has_varieties');
$validated['sync_bamboo'] = $request->has('sync_bamboo');
$validated['low_stock_alert_enabled'] = $request->has('low_stock_alert_enabled');
$validated['is_assembly'] = $request->has('is_assembly');
$validated['show_inventory_to_buyers'] = $request->has('show_inventory_to_buyers');
$validated['is_sellable'] = $request->has('is_sellable');
$validated['is_fpr'] = $request->has('is_fpr');
$validated['is_raw_material'] = $request->has('is_raw_material');
// Store creatives JSON
if (isset($validated['creatives_json'])) {
$validated['creatives'] = $validated['creatives_json'];
unset($validated['creatives_json']);
}
// CRITICAL BUSINESS ISOLATION: Verify brand belongs to the business
$brand = Brand::where('id', $validated['brand_id'])
->where('business_id', $business->id)
->firstOrFail();
// CRITICAL BUSINESS ISOLATION: Ensure the product belongs to this business through brand relationship
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->findOrFail($product->id);
// BUSINESS RULE: Only one active product per brand
if ($request->has('is_active') && $request->boolean('is_active')) {
$existingActiveProduct = Product::where('brand_id', $validated['brand_id'])
->where('is_active', true)
->where('id', '!=', $product->id)
->first();
if ($existingActiveProduct) {
// Check if user wants to force-activate this product
if ($request->has('force_activate') && $request->boolean('force_activate')) {
// Deactivate the existing active product
$existingActiveProduct->update(['is_active' => false]);
} else {
// Show error with option to force activate
return redirect()
->back()
->withInput()
->with('existing_active_product', $existingActiveProduct)
->withErrors(['is_active' => "Only one product can be active per brand at a time. '{$existingActiveProduct->name}' (SKU: {$existingActiveProduct->sku}) is currently active."]);
}
}
}
// Update product
$product->update($validated);
// Handle new image uploads if present
if ($request->hasFile('images')) {
foreach ($request->file('images') as $index => $image) {
$path = $image->store('products', 'public');
$product->images()->create([
'path' => $path,
'type' => 'product',
'is_primary' => $product->images()->count() === 0 && $index === 0,
]);
}
}
return back()->with('success', "Product '{$product->name}' updated successfully!");
return redirect()
->route('seller.business.products.edit', [$business->slug, $product->id])
->with('success', 'Product updated successfully!');
}
/**

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Product;
use App\Models\ProductImage;
use App\Traits\FileStorageHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProductImageController extends Controller
{
use FileStorageHelper;
/**
* Upload a new product image
*/
public function upload(Request $request, Business $business, Product $product)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
// Validate image
$request->validate([
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
]);
// Check if product already has 6 images
if ($product->images()->count() >= 6) {
return response()->json([
'success' => false,
'message' => 'Maximum of 6 images allowed per product'
], 422);
}
// Store the image using trait method
$path = $this->storeFile($request->file('image'), 'products');
// Determine if this should be the primary image (first one)
$isPrimary = $product->images()->count() === 0;
// If setting as primary, unset other primary images
if ($isPrimary) {
$product->images()->update(['is_primary' => false]);
}
// Create the image record
$image = $product->images()->create([
'path' => $path,
'is_primary' => $isPrimary,
'sort_order' => $product->images()->max('sort_order') + 1,
]);
return response()->json([
'success' => true,
'image' => [
'id' => $image->id,
'path' => $image->path,
'is_primary' => $image->is_primary,
]
]);
}
/**
* Delete a product image
*/
public function delete(Business $business, Product $product, ProductImage $image)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
// Ensure image belongs to this product
if ($image->product_id !== $product->id) {
return response()->json([
'success' => false,
'message' => 'Image not found'
], 404);
}
// Delete the file from storage using trait method
$this->deleteFile($image->path);
// If deleting primary image, set next image as primary
if ($image->is_primary) {
$nextImage = $product->images()
->where('id', '!=', $image->id)
->orderBy('sort_order')
->first();
if ($nextImage) {
$nextImage->update(['is_primary' => true]);
}
}
$image->delete();
return response()->json(['success' => true]);
}
/**
* Reorder product images
*/
public function reorder(Request $request, Business $business, Product $product)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
$request->validate([
'order' => 'required|array',
'order.*' => 'required|integer|exists:product_images,id'
]);
$order = $request->input('order');
// Update sort order and set first image as primary
foreach ($order as $index => $imageId) {
$image = ProductImage::where('id', $imageId)
->where('product_id', $product->id)
->first();
if ($image) {
$image->update([
'sort_order' => $index,
'is_primary' => $index === 0, // First image is primary
]);
}
}
return response()->json(['success' => true]);
}
/**
* Set an image as primary
*/
public function setPrimary(Business $business, Product $product, ProductImage $image)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
// Ensure image belongs to this product
if ($image->product_id !== $product->id) {
return response()->json([
'success' => false,
'message' => 'Image not found'
], 404);
}
// Unset all primary flags for this product
$product->images()->update(['is_primary' => false]);
// Set this image as primary
$image->update(['is_primary' => true]);
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ProductLine;
use Illuminate\Http\Request;
class ProductLineController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Business $business)
{
$request->validate([
'name' => 'required|string|max:255|unique:product_lines,name,NULL,id,business_id,' . $business->id,
]);
ProductLine::create([
'business_id' => $business->id,
'name' => $request->name,
]);
return redirect()
->route('seller.business.products.index1', $business->slug)
->with('success', 'Product line created successfully.');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Business $business, ProductLine $productLine)
{
// Ensure business isolation
if ($productLine->business_id !== $business->id) {
abort(404);
}
$request->validate([
'name' => 'required|string|max:255|unique:product_lines,name,' . $productLine->id . ',id,business_id,' . $business->id,
]);
$productLine->update([
'name' => $request->name,
]);
return redirect()
->route('seller.business.products.index1', $business->slug)
->with('success', 'Product line updated successfully.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Business $business, ProductLine $productLine)
{
// Ensure business isolation
if ($productLine->business_id !== $business->id) {
abort(404);
}
$productLine->delete();
return redirect()
->route('seller.business.products.index1', $business->slug)
->with('success', 'Product line deleted successfully.');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Traits\FileStorageHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class StorageTestController extends Controller
{
use FileStorageHelper;
/**
* Test storage configuration
*/
public function test(Request $request)
{
$results = [];
$results['storage_info'] = $this->getStorageInfo();
// Test file upload if provided
if ($request->hasFile('test_file')) {
try {
$file = $request->file('test_file');
// Store test file
$path = $this->storeFile($file, 'tests');
$results['upload'] = [
'success' => true,
'path' => $path,
'url' => $this->getFileUrl($path),
];
// Verify file exists
$disk = Storage::disk($this->getStorageDisk());
$results['verification'] = [
'exists' => $disk->exists($path),
'size' => $disk->size($path),
];
// Delete test file
$deleted = $this->deleteFile($path);
$results['cleanup'] = [
'deleted' => $deleted,
'still_exists' => $disk->exists($path),
];
} catch (\Exception $e) {
$results['error'] = $e->getMessage();
}
}
return response()->json($results, 200, [], JSON_PRETTY_PRINT);
}
/**
* Show test upload form
*/
public function form()
{
return view('storage-test');
}
}

View File

@@ -17,65 +17,152 @@ class Product extends Model implements Auditable
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
protected $fillable = [
// Foreign Keys
'brand_id',
'strain_id',
'parent_product_id',
'packaging_id',
'unit_id',
// Product Identity
'name',
'slug',
'sku',
'barcode',
'description',
'long_description',
// Product Type & Classification
'type',
'category',
'product_line',
'product_link',
'creatives',
// BOM Flags
'is_assembly',
'is_raw_material',
// Configuration Flags
'has_varieties',
'sell_multiples',
'fractional_quantities',
'allow_sample',
'is_fpr',
'is_sellable',
// Pricing
'wholesale_price',
'msrp_price',
'msrp',
'cost_per_unit',
'price_unit',
// Packaging & Units
'net_weight',
'weight_unit',
'units_per_case',
'is_case',
'cased_qty',
'is_box',
'boxed_qty',
// Cannabis-specific
'thc_percentage',
'cbd_percentage',
'thc_content_mg',
'cbd_content_mg',
'strain_value',
'ingredients',
'effects',
'dosage_guidelines',
// Inventory & Status
'quantity_on_hand',
'quantity_allocated',
'reorder_point',
'min_order_qty',
'max_order_qty',
'low_stock_threshold',
'low_stock_alert_enabled',
'sync_bamboo',
'is_active',
'is_featured',
'show_inventory_to_buyers',
'status',
// Compliance & Tracking
'metrc_id',
'license_number',
'arz_total_weight',
'arz_usable_mmj',
'harvest_date',
'package_date',
'test_date',
'launch_date',
// Display & SEO
'sort_order',
'brand_display_order',
'image_path',
'meta_title',
'meta_description',
];
protected $casts = [
// Pricing
'wholesale_price' => 'decimal:2',
'msrp_price' => 'decimal:2',
'msrp' => 'decimal:2',
'cost_per_unit' => 'decimal:2',
// Measurements
'net_weight' => 'decimal:3',
'strain_value' => 'decimal:2',
'arz_total_weight' => 'decimal:3',
'arz_usable_mmj' => 'decimal:3',
// Cannabis
'thc_percentage' => 'decimal:2',
'cbd_percentage' => 'decimal:2',
'thc_content_mg' => 'decimal:2',
'cbd_content_mg' => 'decimal:2',
// Inventory
'quantity_on_hand' => 'integer',
'quantity_allocated' => 'integer',
'reorder_point' => 'integer',
'min_order_qty' => 'integer',
'max_order_qty' => 'integer',
'low_stock_threshold' => 'integer',
// Packaging
'units_per_case' => 'integer',
'cased_qty' => 'integer',
'boxed_qty' => 'integer',
'brand_display_order' => 'integer',
'sort_order' => 'integer',
// Booleans
'is_assembly' => 'boolean',
'is_raw_material' => 'boolean',
'has_varieties' => 'boolean',
'sell_multiples' => 'boolean',
'fractional_quantities' => 'boolean',
'allow_sample' => 'boolean',
'is_fpr' => 'boolean',
'is_sellable' => 'boolean',
'is_case' => 'boolean',
'is_box' => 'boolean',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'sort_order' => 'integer',
'show_inventory_to_buyers' => 'boolean',
'low_stock_alert_enabled' => 'boolean',
'sync_bamboo' => 'boolean',
// Dates
'harvest_date' => 'date',
'package_date' => 'date',
'test_date' => 'date',
'launch_date' => 'date',
];
// Audit configuration - exclude timestamps and system-managed fields
@@ -107,11 +194,26 @@ class Product extends Model implements Auditable
return $this->belongsTo(Brand::class);
}
public function productLine(): BelongsTo
{
return $this->belongsTo(ProductLine::class);
}
public function strain(): BelongsTo
{
return $this->belongsTo(Strain::class);
}
public function packaging(): BelongsTo
{
return $this->belongsTo(ProductPackaging::class, 'packaging_id');
}
public function unit(): BelongsTo
{
return $this->belongsTo(Unit::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Product::class, 'parent_product_id');
@@ -124,7 +226,7 @@ class Product extends Model implements Auditable
public function images(): HasMany
{
return $this->hasMany(ProductImage::class)->orderBy('order');
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
}
public function primaryImage(): HasMany

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductLine extends Model
{
protected $fillable = [
'business_id',
'name',
];
/**
* Get the business that owns the product line.
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* Get the products for this product line.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductPackaging extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'images',
'is_recyclable',
'compliance_details',
'is_active',
];
protected $casts = [
'images' => 'array',
'is_recyclable' => 'boolean',
'is_active' => 'boolean',
];
// Relationships
public function products(): HasMany
{
return $this->hasMany(Product::class, 'packaging_id');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

37
app/Models/Unit.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Unit extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'unit',
'name',
'abbreviation',
'type',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
// Relationships
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Traits;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
trait FileStorageHelper
{
/**
* Get the current storage disk
*/
protected function getStorageDisk(): string
{
return config('filesystems.default');
}
/**
* Check if using S3/MinIO storage
*/
protected function isUsingS3(): bool
{
return $this->getStorageDisk() === 's3';
}
/**
* Store a file and return its path
*
* @param \Illuminate\Http\UploadedFile $file
* @param string $folder
* @param string|null $filename
* @return string
*/
protected function storeFile($file, string $folder, ?string $filename = null): string
{
$disk = Storage::disk($this->getStorageDisk());
if ($filename) {
$path = $folder.'/'.$filename;
$disk->put($path, file_get_contents($file));
return $path;
}
return $file->store($folder, $this->getStorageDisk());
}
/**
* Generate unique filename
*
* @param string $originalName
* @return string
*/
protected function generateUniqueFilename(string $originalName): string
{
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$basename = pathinfo($originalName, PATHINFO_FILENAME);
$slug = Str::slug($basename);
return $slug.'-'.Str::random(8).'.'.$extension;
}
/**
* Get public URL for a file
*
* @param string|null $path
* @return string|null
*/
protected function getFileUrl(?string $path): ?string
{
if (! $path) {
return null;
}
if ($this->isUsingS3()) {
// For S3/MinIO, return full CDN URL
return Storage::disk('s3')->url($path);
}
// For local storage, return app URL + storage path
return asset('storage/'.$path);
}
/**
* Delete a file
*
* @param string|null $path
* @return bool
*/
protected function deleteFile(?string $path): bool
{
if (! $path) {
return false;
}
$disk = Storage::disk($this->getStorageDisk());
if ($disk->exists($path)) {
return $disk->delete($path);
}
return false;
}
/**
* Delete old file and store new one
*
* @param \Illuminate\Http\UploadedFile $newFile
* @param string|null $oldPath
* @param string $folder
* @return string
*/
protected function replaceFile($newFile, ?string $oldPath, string $folder): string
{
// Delete old file if it exists
if ($oldPath) {
$this->deleteFile($oldPath);
}
// Store new file
return $this->storeFile($newFile, $folder);
}
/**
* Get storage info for debugging
*
* @return array
*/
protected function getStorageInfo(): array
{
return [
'disk' => $this->getStorageDisk(),
'is_s3' => $this->isUsingS3(),
'driver' => config('filesystems.disks.'.$this->getStorageDisk().'.driver'),
'endpoint' => config('filesystems.disks.'.$this->getStorageDisk().'.endpoint'),
'bucket' => config('filesystems.disks.'.$this->getStorageDisk().'.bucket'),
];
}
}

56
check_blade.js Normal file
View File

@@ -0,0 +1,56 @@
const fs = require('fs');
const content = fs.readFileSync('resources/views/seller/products/edit11.blade.php', 'utf8');
const lines = content.split('\n');
let depth = 0;
const stack = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
// Skip lines that are Alpine.js @error handlers
if (line.includes('@error') && line.includes('$event')) {
continue;
}
// Check for @if (but not in @endif, @error, @enderror)
if (/@if\s*\(/.test(line) && !/@endif/.test(line)) {
depth++;
stack.push({ line: lineNum, type: 'if', content: line.trim().substring(0, 80) });
console.log(`${lineNum}: [depth +${depth}] @if`);
}
// Check for @elseif
if (/@elseif\s*\(/.test(line)) {
console.log(`${lineNum}: [depth =${depth}] @elseif`);
}
// Check for @else (but not @elseif, @endforelse, @enderror)
if (/@else\b/.test(line) && !/@elseif/.test(line) && !/@endforelse/.test(line) && !/@enderror/.test(line)) {
console.log(`${lineNum}: [depth =${depth}] @else`);
}
// Check for @endif
if (/@endif\b/.test(line)) {
console.log(`${lineNum}: [depth -${depth}] @endif`);
if (depth > 0) {
depth--;
stack.pop();
} else {
console.log(`ERROR: Extra @endif at line ${lineNum}`);
}
}
}
console.log(`\nFinal depth: ${depth}`);
if (depth > 0) {
console.log(`\nUNBALANCED: Missing ${depth} @endif statement(s)`);
console.log('\nUnclosed @if statements:');
stack.forEach(item => {
console.log(` Line ${item.line}: ${item.content}`);
});
} else {
console.log('\nAll @if/@endif pairs are balanced!');
}

42
check_blade.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
$file = 'C:\Users\Boss Man\Documents\GitHub\hub\resources\views\seller\products\edit11.blade.php';
$lines = file($file);
$stack = [];
foreach ($lines as $lineNum => $line) {
$lineNum++; // 1-indexed
// Check for @if (but not @endif, @elseif, etc.)
if (preg_match('/^\s*@if\(/', $line)) {
$stack[] = ['type' => 'if', 'line' => $lineNum];
echo "Line $lineNum: OPEN @if (stack depth: " . count($stack) . ")\n";
}
// Check for @elseif
elseif (preg_match('/^\s*@elseif\(/', $line)) {
echo "Line $lineNum: @elseif\n";
}
// Check for @else
elseif (preg_match('/^\s*@else\s*$/', $line)) {
echo "Line $lineNum: @else\n";
}
// Check for @endif
elseif (preg_match('/^\s*@endif\s*$/', $line)) {
if (empty($stack)) {
echo "ERROR Line $lineNum: @endif without matching @if!\n";
} else {
$opened = array_pop($stack);
echo "Line $lineNum: CLOSE @endif (opened at line {$opened['line']}, stack depth: " . count($stack) . ")\n";
}
}
}
if (!empty($stack)) {
echo "\nERROR: Unclosed @if directives:\n";
foreach ($stack as $item) {
echo " Line {$item['line']}: @if never closed\n";
}
} else {
echo "\nAll @if/@endif directives are balanced!\n";
}

View File

@@ -13,6 +13,7 @@
"laravel/reverb": "^1.6",
"laravel/telescope": "*",
"laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.0",
"owen-it/laravel-auditing": "^14.0",
"predis/predis": "*",
"rahulhaque/laravel-filepond": "*",

274
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8862e7bdefe037022f988b1c4ff4b298",
"content-hash": "6045e661b5737a9c8dd40aeeaf8c9897",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -72,6 +72,157 @@
},
"time": "2025-07-30T15:45:57+00:00"
},
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.359.6",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5",
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.4.5",
"mtdowling/jmespath.php": "^2.8.0",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^0.4.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-pcntl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"symfony/filesystem": "^v6.4.0 || ^v7.1.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "http://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "http://aws.amazon.com/sdkforphp",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.6"
},
"time": "2025-11-05T19:08:10+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
@@ -3431,6 +3582,61 @@
},
"time": "2025-10-20T15:35:26+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.30.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "d286e896083bed3190574b8b088b557b59eb66f5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5",
"reference": "d286e896083bed3190574b8b088b557b59eb66f5",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.295.10",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1"
},
"time": "2025-10-20T15:27:33+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.30.0",
@@ -4038,6 +4244,72 @@
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.33"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.8-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
},
"time": "2024-09-04T18:46:31+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.10.3",

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('units', function (Blueprint $table) {
$table->id();
$table->string('unit'); // Short code (EA, GM, OZ, etc.)
$table->string('name')->nullable(); // Full name (Each, Grams, Ounces, etc.)
$table->string('abbreviation')->nullable(); // Alternative abbreviation
$table->string('type')->default('weight'); // weight, volume, count
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('unit');
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('units');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_packagings', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->json('images')->nullable();
$table->boolean('is_recyclable')->default(false);
$table->text('compliance_details')->nullable(); // Compliance information
$table->boolean('is_active')->default(true);
$table->timestamps();
// Indexes
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_packagings');
}
};

View File

@@ -0,0 +1,148 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// Foreign Keys
$table->foreignId('packaging_id')->nullable()->after('strain_id')->constrained('product_packagings')->onDelete('set null');
$table->foreignId('unit_id')->nullable()->after('packaging_id')->constrained('units')->onDelete('set null');
// Metadata
$table->string('product_line')->nullable()->after('category');
$table->text('product_link')->nullable()->after('product_line'); // External URL
$table->text('creatives')->nullable()->after('product_link'); // Marketing assets
$table->string('barcode')->nullable()->after('sku');
$table->integer('brand_display_order')->nullable()->after('sort_order');
// Configuration Flags
$table->boolean('has_varieties')->default(false)->after('is_raw_material');
$table->boolean('sell_multiples')->default(false)->after('has_varieties');
$table->boolean('fractional_quantities')->default(false)->after('sell_multiples');
$table->boolean('allow_sample')->default(false)->after('fractional_quantities');
$table->boolean('is_fpr')->default(false)->after('allow_sample'); // Finished Product Ready
$table->boolean('is_sellable')->default(false)->after('is_fpr');
// Case/Box Packaging
$table->boolean('is_case')->default(false)->after('units_per_case');
$table->integer('cased_qty')->default(0)->after('is_case');
$table->boolean('is_box')->default(false)->after('cased_qty');
$table->integer('boxed_qty')->default(0)->after('is_box');
// Dates
$table->date('launch_date')->nullable()->after('test_date');
// Inventory Management
$table->integer('inventory_manage_pct')->nullable()->after('reorder_point'); // 0-100%
$table->integer('min_order_qty')->nullable()->after('inventory_manage_pct');
$table->integer('max_order_qty')->nullable()->after('min_order_qty');
$table->integer('low_stock_threshold')->nullable()->after('max_order_qty');
$table->boolean('low_stock_alert_enabled')->default(false)->after('low_stock_threshold');
// Strain Value
$table->decimal('strain_value', 8, 2)->nullable()->after('cbd_content_mg');
// Arizona Compliance
$table->decimal('arz_total_weight', 10, 3)->nullable()->after('license_number');
$table->decimal('arz_usable_mmj', 10, 3)->nullable()->after('arz_total_weight');
// Extended Descriptions
$table->text('long_description')->nullable()->after('description');
$table->text('ingredients')->nullable()->after('long_description');
$table->text('effects')->nullable()->after('ingredients');
$table->text('dosage_guidelines')->nullable()->after('effects');
// Visibility
$table->boolean('show_inventory_to_buyers')->default(false)->after('is_featured');
// Threshold Automation
$table->integer('decreasing_qty_threshold')->nullable()->after('low_stock_alert_enabled');
$table->string('decreasing_qty_action')->nullable()->after('decreasing_qty_threshold');
$table->integer('increasing_qty_threshold')->nullable()->after('decreasing_qty_action');
$table->string('increasing_qty_action')->nullable()->after('increasing_qty_threshold');
// Enhanced Status (update from boolean to enum)
$table->enum('status', ['available', 'archived', 'sample', 'backorder', 'internal', 'unavailable'])
->default('available')
->after('is_active');
// MSRP for retail pricing
$table->decimal('msrp', 10, 2)->nullable()->after('wholesale_price');
// Add indexes for new foreign keys and frequently queried fields
$table->index('packaging_id');
$table->index('unit_id');
$table->index('status');
$table->index('is_sellable');
$table->index('launch_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// Drop indexes first
$table->dropIndex(['packaging_id']);
$table->dropIndex(['unit_id']);
$table->dropIndex(['status']);
$table->dropIndex(['is_sellable']);
$table->dropIndex(['launch_date']);
// Drop foreign keys
$table->dropForeign(['packaging_id']);
$table->dropForeign(['unit_id']);
// Drop columns in reverse order
$table->dropColumn([
'packaging_id',
'unit_id',
'product_line',
'product_link',
'creatives',
'barcode',
'brand_display_order',
'has_varieties',
'sell_multiples',
'fractional_quantities',
'allow_sample',
'is_fpr',
'is_sellable',
'is_case',
'cased_qty',
'is_box',
'boxed_qty',
'launch_date',
'inventory_manage_pct',
'min_order_qty',
'max_order_qty',
'low_stock_threshold',
'low_stock_alert_enabled',
'strain_value',
'arz_total_weight',
'arz_usable_mmj',
'long_description',
'ingredients',
'effects',
'dosage_guidelines',
'show_inventory_to_buyers',
'decreasing_qty_threshold',
'decreasing_qty_action',
'increasing_qty_threshold',
'increasing_qty_action',
'status',
'msrp',
]);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// Add sync_bamboo field - when enabled, inventory is synced from Bamboo
$table->boolean('sync_bamboo')->default(false)->after('show_inventory_to_buyers');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('sync_bamboo');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->timestamps();
// Ensure unique product line names per business
$table->unique(['business_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_lines');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// Drop the old text column
$table->dropColumn('product_line');
// Add foreign key to product_lines table (nullable to allow products without a line)
$table->foreignId('product_line_id')->nullable()->after('brand_id')->constrained()->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// Drop the foreign key
$table->dropForeign(['product_line_id']);
$table->dropColumn('product_line_id');
// Restore the old text column
$table->string('product_line')->nullable();
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('product_images', function (Blueprint $table) {
$table->integer('sort_order')->default(0)->after('is_primary');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('product_images', function (Blueprint $table) {
$table->dropColumn('sort_order');
});
}
};

View File

@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Models\Business;
use App\Models\ProductLine;
use Illuminate\Database\Seeder;
class ProductLineSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Default product line names
$defaultLines = [
'Flower',
'Resin',
'Rosin',
'Pre-Rolls',
'Wax',
];
// Get all seller businesses
$sellerBusinesses = Business::where('type', 'seller')
->orWhere('type', 'both')
->get();
foreach ($sellerBusinesses as $business) {
foreach ($defaultLines as $lineName) {
ProductLine::firstOrCreate(
[
'business_id' => $business->id,
'name' => $lineName,
]
);
}
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Database\Seeders;
use App\Models\ProductPackaging;
use Illuminate\Database\Seeder;
class ProductPackagingSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$packagings = [
[
'name' => 'Glass Jar',
'description' => 'Standard glass jar packaging for flower products',
'is_recyclable' => true,
'is_active' => true,
],
[
'name' => 'Plastic Container',
'description' => 'Child-resistant plastic container',
'is_recyclable' => false,
'is_active' => true,
],
[
'name' => 'Mylar Bag',
'description' => 'Resealable mylar bag packaging',
'is_recyclable' => false,
'is_active' => true,
],
[
'name' => 'Pre-Roll Tube',
'description' => 'Individual pre-roll tube packaging',
'is_recyclable' => false,
'is_active' => true,
],
[
'name' => 'Tin Container',
'description' => 'Metal tin container for concentrates',
'is_recyclable' => true,
'is_active' => true,
],
[
'name' => 'Cartridge Box',
'description' => 'Cardboard box for vape cartridges',
'is_recyclable' => true,
'is_active' => true,
],
[
'name' => 'Bottle',
'description' => 'Bottle packaging for tinctures and oils',
'is_recyclable' => true,
'is_active' => true,
],
[
'name' => 'Blister Pack',
'description' => 'Blister pack for edibles',
'is_recyclable' => false,
'is_active' => true,
],
[
'name' => 'Miscellaneous',
'description' => 'Other packaging types',
'is_recyclable' => false,
'is_active' => true,
],
];
foreach ($packagings as $packagingData) {
ProductPackaging::updateOrCreate(
['name' => $packagingData['name']],
$packagingData
);
}
$this->command->info('Created '.count($packagings).' product packaging types');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class StrainClassificationSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Database\Seeders;
use App\Models\Unit;
use Illuminate\Database\Seeder;
class UnitSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$units = [
[
'unit' => 'EA',
'name' => 'Each',
'abbreviation' => 'ea',
'type' => 'count',
'is_active' => true,
],
[
'unit' => 'GM',
'name' => 'Grams',
'abbreviation' => 'g',
'type' => 'weight',
'is_active' => true,
],
[
'unit' => 'OZ',
'name' => 'Ounces',
'abbreviation' => 'oz',
'type' => 'weight',
'is_active' => true,
],
[
'unit' => 'FL OZ',
'name' => 'Fluid Ounces',
'abbreviation' => 'fl oz',
'type' => 'volume',
'is_active' => true,
],
[
'unit' => 'ML',
'name' => 'Milliliters',
'abbreviation' => 'ml',
'type' => 'volume',
'is_active' => true,
],
[
'unit' => 'LB',
'name' => 'Pounds',
'abbreviation' => 'lb',
'type' => 'weight',
'is_active' => true,
],
[
'unit' => 'KG',
'name' => 'Kilograms',
'abbreviation' => 'kg',
'type' => 'weight',
'is_active' => true,
],
[
'unit' => 'L',
'name' => 'Liters',
'abbreviation' => 'l',
'type' => 'volume',
'is_active' => true,
],
];
foreach ($units as $unitData) {
Unit::updateOrCreate(
['unit' => $unitData['unit']],
$unitData
);
}
$this->command->info('Created '.count($units).' units');
}
}

View File

@@ -3,12 +3,13 @@
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view("landing");
return view('landing');
});
Route::get('/{name}', function ($name) {
if (view()->exists($name)) {
return view($name);
}
return view("not-found");
return view('not-found');
});

View File

@@ -13,6 +13,22 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<!-- Remove spinner arrows from number inputs globally -->
<style>
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>
</head>
<body class="font-sans antialiased">
<script>

View File

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

View File

@@ -79,7 +79,7 @@
</div>
<footer class="text-center text-sm text-base-content/secondary py-4 bg-base-100">
<p>
&copy; {{ date('Y') }} cannabrands |
&copy; {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
@if($appVersion === 'dev')
<span class="text-yellow-500 font-semibold">DEV</span>
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,133 +23,73 @@
</div>
</div>
<!-- Search and Filters -->
<!-- Tabs Navigation -->
<div class="mt-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-6 gap-3">
<input type="text" name="search" placeholder="Search products..." class="input input-bordered input-sm w-full" value="{{ request('search') }}" />
<select name="type" class="select select-bordered select-sm w-full">
<option value="">All Types</option>
<option value="flower" {{ request('type') === 'flower' ? 'selected' : '' }}>Flower</option>
<option value="pre-roll" {{ request('type') === 'pre-roll' ? 'selected' : '' }}>Pre-Roll</option>
<option value="concentrate" {{ request('type') === 'concentrate' ? 'selected' : '' }}>Concentrate</option>
<option value="edible" {{ request('type') === 'edible' ? 'selected' : '' }}>Edible</option>
</select>
@if(!session('selected_brand_id') && $brands->count() > 1)
<select name="brand_id" class="select select-bordered select-sm w-full">
<option value="">All Brands</option>
@foreach($brands as $brand)
<option value="{{ $brand->id }}" {{ request('brand_id') == $brand->id ? 'selected' : '' }}>{{ $brand->name }}</option>
@endforeach
</select>
@endif
<select name="status" class="select select-bordered select-sm w-full">
<option value="">All Status</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>Active</option>
<option value="inactive" {{ request('status') === 'inactive' ? 'selected' : '' }}>Inactive</option>
</select>
<select name="stock" class="select select-bordered select-sm w-full">
<option value="">All Stock</option>
<option value="low" {{ request('stock') === 'low' ? 'selected' : '' }}>Low Stock</option>
</select>
<div class="flex gap-2 w-full">
<button type="submit" class="btn btn-primary btn-sm flex-1">Filter</button>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
<div role="tablist" class="tabs tabs-boxed bg-base-200 p-1">
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Active Products" checked />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.active-products')
</div>
</div>
</div>
<!-- Products Table -->
<div class="mt-6">
<div class="card bg-base-100 shadow overflow-visible">
<div class="card-body p-0 overflow-visible">
@if($products->count() > 0)
<div class="overflow-x-auto overflow-y-visible">
<table class="table table-zebra">
<thead>
<tr>
<th>Image</th>
<th>SKU</th>
<th>Name</th>
<th>Brand</th>
<th>Type</th>
<th>Price</th>
<th>Stock</th>
<th>Status</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
@foreach($products as $product)
<tr>
<td>
@php
$img = $product->images()->where('is_primary', true)->first() ?? $product->images()->first();
@endphp
@if($img)
<div class="avatar"><div class="mask mask-squircle w-10 h-10"><img src="{{ asset('storage/' . $img->path) }}" /></div></div>
@else
<div class="avatar placeholder"><div class="bg-neutral text-neutral-content mask mask-squircle w-10"><span class="icon-[lucide--box] size-5"></span></div></div>
@endif
</td>
<td><span class="font-mono text-xs">{{ $product->sku }}</span></td>
<td><div class="font-medium">{{ $product->name }}</div></td>
<td><span class="badge badge-outline badge-sm whitespace-nowrap">{{ $product->brand->name }}</span></td>
<td><span class="badge badge-primary badge-sm">{{ ucfirst($product->type) }}</span></td>
<td>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="font-semibold">${{ number_format($product->wholesale_price, 2) }}</span>
<span class="text-xs text-base-content/60">/ {{ $product->price_unit === 'each' ? 'ea' : ($product->price_unit === 'gram' ? 'g' : $product->price_unit) }}</span>
</div>
</td>
<td><span class="font-medium {{ $product->quantity_on_hand <= ($product->reorder_point ?? 0) ? 'text-error' : 'text-success' }}">{{ $product->quantity_on_hand ?? 0 }}</span></td>
<td>@if($product->is_active)<span class="badge badge-success badge-sm">Active</span>@else<span class="badge badge-warning badge-sm">Inactive</span>@endif</td>
<td>
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-xs">
<span class="icon-[lucide--more-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->id]) }}">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</a>
</li>
<li>
<form action="{{ route('seller.business.products.destroy', [$business->slug, $product->id]) }}" method="POST" onsubmit="return confirm('Delete this product?')" class="w-full">
@csrf
@method('DELETE')
<button type="submit" class="text-error w-full text-left">
<span class="icon-[lucide--trash-2] size-4"></span>
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="p-4 border-t border-base-300">{{ $products->links() }}</div>
@else
<div class="text-center py-12">
<span class="icon-[lucide--box] size-16 text-base-content/20 mx-auto mb-4 block"></span>
<h3 class="text-lg font-semibold mb-2">No products found</h3>
<p class="text-base-content/60 mb-4">Get started by creating your first product</p>
<a href="{{ route('seller.business.products.create', $business->slug) }}" class="btn btn-primary">
<span class="icon-[lucide--plus] size-5"></span>
Add Product
</a>
</div>
@endif
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Inventory" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.inventory-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Archived" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.archived-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Listings" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.listings-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Batch Management" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.batch-management-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Custom Menus" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.custom-menus-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Promotions" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.promotions-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Product Lines" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.product-lines-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Templates" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.templates-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Bulk Add/Edit" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.bulk-add-edit-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Strains" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.strains-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Product Packaging" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.product-packaging-tab')
</div>
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="FAQ's" />
<div role="tabpanel" class="tab-content pt-6">
@include('seller.products.partials.faqs-tab')
</div>
</div>
</div>
@endsection
@endsection

View File

@@ -0,0 +1,125 @@
<!-- Search and Filters -->
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-6 gap-3">
<input type="text" name="search" placeholder="Search products..." class="input input-bordered input-sm w-full" value="{{ request('search') }}" />
<select name="type" class="select select-bordered select-sm w-full">
<option value="">All Types</option>
<option value="flower" {{ request('type') === 'flower' ? 'selected' : '' }}>Flower</option>
<option value="pre-roll" {{ request('type') === 'pre-roll' ? 'selected' : '' }}>Pre-Roll</option>
<option value="concentrate" {{ request('type') === 'concentrate' ? 'selected' : '' }}>Concentrate</option>
<option value="edible" {{ request('type') === 'edible' ? 'selected' : '' }}>Edible</option>
</select>
@if(!session('selected_brand_id') && $brands->count() > 1)
<select name="brand_id" class="select select-bordered select-sm w-full">
<option value="">All Brands</option>
@foreach($brands as $brand)
<option value="{{ $brand->id }}" {{ request('brand_id') == $brand->id ? 'selected' : '' }}>{{ $brand->name }}</option>
@endforeach
</select>
@endif
<select name="status" class="select select-bordered select-sm w-full">
<option value="">All Status</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>Active</option>
<option value="inactive" {{ request('status') === 'inactive' ? 'selected' : '' }}>Inactive</option>
</select>
<select name="stock" class="select select-bordered select-sm w-full">
<option value="">All Stock</option>
<option value="low" {{ request('stock') === 'low' ? 'selected' : '' }}>Low Stock</option>
</select>
<div class="flex gap-2 w-full">
<button type="submit" class="btn btn-primary btn-sm flex-1">Filter</button>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="btn btn-ghost btn-sm">Clear</a>
</div>
</form>
</div>
</div>
<!-- Products Table -->
<div class="card bg-base-100 shadow overflow-visible">
<div class="card-body p-0 overflow-visible">
@if($products->count() > 0)
<div class="overflow-x-auto overflow-y-visible">
<table class="table table-zebra">
<thead>
<tr>
<th>Image</th>
<th>SKU</th>
<th>Name</th>
<th>Brand</th>
<th>Type</th>
<th>Price</th>
<th>Stock</th>
<th>Status</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
@foreach($products as $product)
<tr>
<td>
@php
$img = $product->images()->where('is_primary', true)->first() ?? $product->images()->first();
@endphp
@if($img)
<div class="avatar"><div class="mask mask-squircle w-10 h-10"><img src="{{ asset('storage/' . $img->path) }}" /></div></div>
@else
<div class="avatar placeholder"><div class="bg-neutral text-neutral-content mask mask-squircle w-10"><span class="icon-[lucide--box] size-5"></span></div></div>
@endif
</td>
<td><span class="font-mono text-xs">{{ $product->sku }}</span></td>
<td><div class="font-medium">{{ $product->name }}</div></td>
<td><span class="badge badge-outline badge-sm whitespace-nowrap">{{ $product->brand->name }}</span></td>
<td><span class="badge badge-primary badge-sm">{{ ucfirst($product->type) }}</span></td>
<td>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="font-semibold">${{ number_format($product->wholesale_price, 2) }}</span>
<span class="text-xs text-base-content/60">/ {{ $product->price_unit === 'each' ? 'ea' : ($product->price_unit === 'gram' ? 'g' : $product->price_unit) }}</span>
</div>
</td>
<td><span class="font-medium {{ $product->quantity_on_hand <= ($product->reorder_point ?? 0) ? 'text-error' : 'text-success' }}">{{ $product->quantity_on_hand ?? 0 }}</span></td>
<td>@if($product->is_active)<span class="badge badge-success badge-sm">Active</span>@else<span class="badge badge-warning badge-sm">Inactive</span>@endif</td>
<td>
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-xs">
<span class="icon-[lucide--more-vertical] size-4"></span>
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->id]) }}">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</a>
</li>
<li>
<form action="{{ route('seller.business.products.destroy', [$business->slug, $product->id]) }}" method="POST" onsubmit="return confirm('Delete this product?')" class="w-full">
@csrf
@method('DELETE')
<button type="submit" class="text-error w-full text-left">
<span class="icon-[lucide--trash-2] size-4"></span>
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="p-4 border-t border-base-300">{{ $products->links() }}</div>
@else
<div class="text-center py-12">
<span class="icon-[lucide--box] size-16 text-base-content/20 mx-auto mb-4 block"></span>
<h3 class="text-lg font-semibold mb-2">No products found</h3>
<p class="text-base-content/60 mb-4">Get started by creating your first product</p>
<a href="{{ route('seller.business.products.create', $business->slug) }}" class="btn btn-primary">
<span class="icon-[lucide--plus] size-5"></span>
Add Product
</a>
</div>
@endif
</div>
</div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1,124 @@
<div class="space-y-6">
<!-- Header with Add Button -->
<div class="flex justify-between items-center">
<div>
<h2 class="text-xl font-semibold">Product Lines</h2>
<p class="text-sm text-base-content/60">Manage product line categories for your business</p>
</div>
<button type="button" onclick="product_line_modal.showModal()" class="btn btn-primary btn-sm">
<span class="icon-[lucide--plus] size-4"></span>
Add Product Line
</button>
</div>
<!-- Product Lines Table -->
<div class="card bg-base-100 shadow">
<div class="card-body p-0">
@if($productLines->count() > 0)
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Name</th>
<th>Products Count</th>
<th>Created</th>
<th class="w-24">Actions</th>
</tr>
</thead>
<tbody>
@foreach($productLines as $productLine)
<tr>
<td>
<div class="font-medium">{{ $productLine->name }}</div>
</td>
<td>
<span class="badge badge-outline badge-sm">{{ $productLine->products_count }} products</span>
</td>
<td>
<span class="text-sm text-base-content/60">{{ $productLine->created_at->format('M d, Y') }}</span>
</td>
<td>
<div class="flex gap-2">
<button type="button"
onclick="editProductLine({{ $productLine->id }}, '{{ addslashes($productLine->name) }}')"
class="btn btn-ghost btn-xs">
<span class="icon-[lucide--pencil] size-4"></span>
</button>
<form action="{{ route('seller.business.product-lines.destroy', [$business->slug, $productLine->id]) }}"
method="POST"
onsubmit="return confirm('Delete this product line? Products using this line will not be deleted.')"
class="inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-ghost btn-xs text-error">
<span class="icon-[lucide--trash-2] size-4"></span>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12">
<span class="icon-[lucide--list] size-16 text-base-content/20 mx-auto mb-4 block"></span>
<h3 class="text-lg font-semibold mb-2">No product lines yet</h3>
<p class="text-base-content/60 mb-4">Create product lines to organize your products</p>
<button type="button" onclick="product_line_modal.showModal()" class="btn btn-primary btn-sm">
<span class="icon-[lucide--plus] size-4"></span>
Add Product Line
</button>
</div>
@endif
</div>
</div>
</div>
<!-- Add/Edit Product Line Modal -->
<dialog id="product_line_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4" id="modal_title">Add Product Line</h3>
<form id="product_line_form" method="POST" action="{{ route('seller.business.product-lines.store', $business->slug) }}">
@csrf
<input type="hidden" id="form_method" name="_method" value="POST">
<div class="form-control">
<label class="label">
<span class="label-text">Product Line Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" id="product_line_name" required class="input input-bordered" placeholder="e.g., Flower, Resin, Rosin">
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="modal-action">
<button type="button" onclick="product_line_modal.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function editProductLine(id, name) {
document.getElementById('modal_title').textContent = 'Edit Product Line';
document.getElementById('product_line_name').value = name;
document.getElementById('form_method').value = 'PUT';
document.getElementById('product_line_form').action = '/s/{{ $business->slug }}/product-lines/' + id;
product_line_modal.showModal();
}
// Reset form when modal closes
document.getElementById('product_line_modal').addEventListener('close', function() {
document.getElementById('modal_title').textContent = 'Add Product Line';
document.getElementById('product_line_name').value = '';
document.getElementById('form_method').value = 'POST';
document.getElementById('product_line_form').action = '{{ route('seller.business.product-lines.store', $business->slug) }}';
});
</script>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1 @@
<div class="card bg-base-100 shadow"><div class="card-body"><div class="text-center py-12"><span class="icon-[lucide--construction] size-16 text-base-content/20 mx-auto mb-4 block"></span><h3 class="text-lg font-semibold mb-2">Coming Soon</h3><p class="text-base-content/60">This feature is under development</p></div></div></div>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Storage Test</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="p-8">
<div class="max-w-2xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">File Storage Test</h2>
<div class="alert alert-info">
<span class="text-sm">Current Disk: <strong>{{ config('filesystems.default') }}</strong></span>
</div>
<form action="{{ route('storage.test') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-control">
<label class="label">
<span class="label-text">Upload Test File</span>
</label>
<input type="file" name="test_file" class="file-input file-input-bordered" required>
</div>
<div class="card-actions justify-end mt-4">
<button type="submit" class="btn btn-primary">Test Upload</button>
</div>
</form>
<div class="divider">Storage Info</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
<tr>
<td class="font-semibold">Disk</td>
<td>{{ config('filesystems.default') }}</td>
</tr>
<tr>
<td class="font-semibold">Driver</td>
<td>{{ config('filesystems.disks.'.config('filesystems.default').'.driver') }}</td>
</tr>
@if(config('filesystems.default') === 's3')
<tr>
<td class="font-semibold">Endpoint</td>
<td>{{ config('filesystems.disks.s3.endpoint') }}</td>
</tr>
<tr>
<td class="font-semibold">Bucket</td>
<td>{{ config('filesystems.disks.s3.bucket') }}</td>
</tr>
<tr>
<td class="font-semibold">URL</td>
<td>{{ config('filesystems.disks.s3.url') }}</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -183,6 +183,7 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::get('/create', [\App\Http\Controllers\Seller\ProductController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Seller\ProductController::class, 'store'])->name('store');
Route::get('/{product}/edit', [\App\Http\Controllers\Seller\ProductController::class, 'edit'])->name('edit');
Route::get('/{product}/edit1', [\App\Http\Controllers\Seller\ProductController::class, 'edit1'])->name('edit1');
Route::put('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'update'])->name('update');
Route::delete('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'destroy'])->name('destroy');
@@ -195,6 +196,21 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::delete('/component/{component}', [\App\Http\Controllers\Seller\Product\BomController::class, 'detach'])->name('detach');
Route::post('/reorder', [\App\Http\Controllers\Seller\Product\BomController::class, 'reorder'])->name('reorder');
});
// Product Image Management
Route::prefix('{product}/images')->name('images.')->group(function () {
Route::post('/upload', [\App\Http\Controllers\Seller\ProductImageController::class, 'upload'])->name('upload');
Route::delete('/{image}', [\App\Http\Controllers\Seller\ProductImageController::class, 'delete'])->name('delete');
Route::post('/reorder', [\App\Http\Controllers\Seller\ProductImageController::class, 'reorder'])->name('reorder');
Route::post('/{image}/set-primary', [\App\Http\Controllers\Seller\ProductImageController::class, 'setPrimary'])->name('set-primary');
});
});
// Product Lines Management (business-scoped)
Route::prefix('product-lines')->name('product-lines.')->group(function () {
Route::post('/', [\App\Http\Controllers\Seller\ProductLineController::class, 'store'])->name('store');
Route::put('/{productLine}', [\App\Http\Controllers\Seller\ProductLineController::class, 'update'])->name('update');
Route::delete('/{productLine}', [\App\Http\Controllers\Seller\ProductLineController::class, 'destroy'])->name('destroy');
});
// Component Management (business-scoped)

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\StorageTestController;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -198,3 +199,9 @@ Route::prefix('api')->group(function () {
->name('api.check-email')
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
});
// Storage Test Routes (Development/Testing Only)
Route::middleware(['auth'])->group(function () {
Route::get('/storage-test', [StorageTestController::class, 'form'])->name('storage.test.form');
Route::post('/storage-test', [StorageTestController::class, 'test'])->name('storage.test');
});