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:
9
.blade-formatter.json
Normal file
9
.blade-formatter.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"indentSize": 4,
|
||||||
|
"wrapAttributes": "auto",
|
||||||
|
"wrapLineLength": 120,
|
||||||
|
"endWithNewLine": true,
|
||||||
|
"useTabs": false,
|
||||||
|
"sortTailwindcssClasses": true,
|
||||||
|
"sortHtmlAttributes": "none"
|
||||||
|
}
|
||||||
35
.claude/notes/number-input-spinners-removed.md
Normal file
35
.claude/notes/number-input-spinners-removed.md
Normal 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
|
||||||
@@ -15,7 +15,11 @@
|
|||||||
"Bash(php -l:*)",
|
"Bash(php -l:*)",
|
||||||
"Bash(curl:*)",
|
"Bash(curl:*)",
|
||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Bash(docker update:*)"
|
"Bash(docker update:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(sed:*)",
|
||||||
|
"Bash(php artisan:*)",
|
||||||
|
"Bash(php check_blade.php:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
15
.env.example
15
.env.example
@@ -77,10 +77,25 @@ MAIL_ENCRYPTION=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
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_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
|
AWS_ENDPOINT=
|
||||||
|
AWS_URL=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
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}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -58,3 +58,4 @@ core.*
|
|||||||
!resources/**/*.png
|
!resources/**/*.png
|
||||||
!resources/**/*.jpg
|
!resources/**/*.jpg
|
||||||
!resources/**/*.jpeg
|
!resources/**/*.jpeg
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
20
.stylelintrc.json
Normal file
20
.stylelintrc.json
Normal 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/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -35,6 +35,19 @@ ALL routes need auth + user type middleware except public pages
|
|||||||
❌ No IF/ELSE logic in migrations (not supported)
|
❌ No IF/ELSE logic in migrations (not supported)
|
||||||
✅ Use Laravel Schema builder or conditional PHP code
|
✅ 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
|
## Tech Stack by Area
|
||||||
@@ -106,4 +119,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
|||||||
✅ Use Eloquent (never raw SQL)
|
✅ Use Eloquent (never raw SQL)
|
||||||
✅ Protect routes with middleware
|
✅ Protect routes with middleware
|
||||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||||
|
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||||
✅ Run tests before committing
|
✅ Run tests before committing
|
||||||
|
|||||||
258
PRODUCT2_INSTRUCTIONS.md
Normal file
258
PRODUCT2_INSTRUCTIONS.md
Normal 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?
|
||||||
@@ -6,6 +6,10 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Brand;
|
use App\Models\Brand;
|
||||||
use App\Models\Business;
|
use App\Models\Business;
|
||||||
use App\Models\Product;
|
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\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -69,7 +73,13 @@ class ProductController extends Controller
|
|||||||
// Get all brands for filter dropdown
|
// Get all brands for filter dropdown
|
||||||
$brands = $business->brands()->orderBy('name')->get();
|
$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)
|
public function edit(Business $business, Product $product)
|
||||||
{
|
{
|
||||||
// Eager load relationships
|
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||||
$product->load(['brand', 'images']);
|
$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
|
// Prepare dropdown data
|
||||||
if (! $product->belongsToBusiness($business)) {
|
$brands = Brand::where('business_id', $business->id)->get();
|
||||||
abort(403, 'This product does not belong to your business');
|
$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
|
// Status options
|
||||||
$audits = $product->audits()
|
$statusOptions = [
|
||||||
->with('user')
|
'active' => 'Active',
|
||||||
->latest()
|
'inactive' => 'Inactive',
|
||||||
->paginate(10);
|
'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)
|
public function update(Request $request, Business $business, Product $product)
|
||||||
{
|
{
|
||||||
// Verify product belongs to this business
|
// Comprehensive validation
|
||||||
if (! $product->belongsToBusiness($business)) {
|
|
||||||
abort(403, 'This product does not belong to your business');
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
// Basic Information
|
||||||
'brand_id' => 'required|exists:brands,id',
|
'brand_id' => 'required|exists:brands,id',
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'sku' => 'required|string|max:100|unique:products,sku,'.$product->id,
|
'sku' => 'required|string|max:100',
|
||||||
'description' => 'nullable|string',
|
'barcode' => 'nullable|string|max:100',
|
||||||
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
'type' => 'nullable|string',
|
||||||
'category' => 'nullable|string|max:100',
|
'product_line_id' => 'nullable|exists:product_lines,id',
|
||||||
'wholesale_price' => 'required|numeric|min:0',
|
'unit_id' => 'required|exists:units,id',
|
||||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
'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',
|
'net_weight' => 'nullable|numeric|min:0',
|
||||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
'weight_unit' => 'nullable|string|max:20',
|
||||||
'units_per_case' => 'nullable|integer|min:1',
|
'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',
|
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
||||||
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
||||||
'is_active' => 'boolean',
|
'strain_id' => 'nullable|exists:strains,id',
|
||||||
'is_featured' => 'boolean',
|
'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
|
// Convert checkboxes to boolean
|
||||||
$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
|
|
||||||
$validated['is_active'] = $request->has('is_active');
|
$validated['is_active'] = $request->has('is_active');
|
||||||
$validated['is_featured'] = $request->has('is_featured');
|
$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
|
// Update product
|
||||||
$product->update($validated);
|
$product->update($validated);
|
||||||
|
|
||||||
// Handle new image uploads if present
|
return redirect()
|
||||||
if ($request->hasFile('images')) {
|
->route('seller.business.products.edit', [$business->slug, $product->id])
|
||||||
foreach ($request->file('images') as $index => $image) {
|
->with('success', 'Product updated successfully!');
|
||||||
$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!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
166
app/Http/Controllers/Seller/ProductImageController.php
Normal file
166
app/Http/Controllers/Seller/ProductImageController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Controllers/Seller/ProductLineController.php
Normal file
70
app/Http/Controllers/Seller/ProductLineController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Http/Controllers/StorageTestController.php
Normal file
63
app/Http/Controllers/StorageTestController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,65 +17,152 @@ class Product extends Model implements Auditable
|
|||||||
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
|
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
// Foreign Keys
|
||||||
'brand_id',
|
'brand_id',
|
||||||
'strain_id',
|
'strain_id',
|
||||||
'parent_product_id',
|
'parent_product_id',
|
||||||
|
'packaging_id',
|
||||||
|
'unit_id',
|
||||||
|
|
||||||
|
// Product Identity
|
||||||
'name',
|
'name',
|
||||||
'slug',
|
'slug',
|
||||||
'sku',
|
'sku',
|
||||||
|
'barcode',
|
||||||
'description',
|
'description',
|
||||||
|
'long_description',
|
||||||
|
|
||||||
|
// Product Type & Classification
|
||||||
'type',
|
'type',
|
||||||
'category',
|
'category',
|
||||||
|
'product_line',
|
||||||
|
'product_link',
|
||||||
|
'creatives',
|
||||||
|
|
||||||
|
// BOM Flags
|
||||||
'is_assembly',
|
'is_assembly',
|
||||||
'is_raw_material',
|
'is_raw_material',
|
||||||
|
|
||||||
|
// Configuration Flags
|
||||||
|
'has_varieties',
|
||||||
|
'sell_multiples',
|
||||||
|
'fractional_quantities',
|
||||||
|
'allow_sample',
|
||||||
|
'is_fpr',
|
||||||
|
'is_sellable',
|
||||||
|
|
||||||
|
// Pricing
|
||||||
'wholesale_price',
|
'wholesale_price',
|
||||||
'msrp_price',
|
'msrp',
|
||||||
'cost_per_unit',
|
'cost_per_unit',
|
||||||
'price_unit',
|
'price_unit',
|
||||||
|
|
||||||
|
// Packaging & Units
|
||||||
'net_weight',
|
'net_weight',
|
||||||
'weight_unit',
|
'weight_unit',
|
||||||
'units_per_case',
|
'units_per_case',
|
||||||
|
'is_case',
|
||||||
|
'cased_qty',
|
||||||
|
'is_box',
|
||||||
|
'boxed_qty',
|
||||||
|
|
||||||
|
// Cannabis-specific
|
||||||
'thc_percentage',
|
'thc_percentage',
|
||||||
'cbd_percentage',
|
'cbd_percentage',
|
||||||
'thc_content_mg',
|
'thc_content_mg',
|
||||||
'cbd_content_mg',
|
'cbd_content_mg',
|
||||||
|
'strain_value',
|
||||||
|
'ingredients',
|
||||||
|
'effects',
|
||||||
|
'dosage_guidelines',
|
||||||
|
|
||||||
|
// Inventory & Status
|
||||||
'quantity_on_hand',
|
'quantity_on_hand',
|
||||||
'quantity_allocated',
|
'quantity_allocated',
|
||||||
'reorder_point',
|
'reorder_point',
|
||||||
|
'min_order_qty',
|
||||||
|
'max_order_qty',
|
||||||
|
'low_stock_threshold',
|
||||||
|
'low_stock_alert_enabled',
|
||||||
|
'sync_bamboo',
|
||||||
'is_active',
|
'is_active',
|
||||||
'is_featured',
|
'is_featured',
|
||||||
|
'show_inventory_to_buyers',
|
||||||
|
'status',
|
||||||
|
|
||||||
|
// Compliance & Tracking
|
||||||
'metrc_id',
|
'metrc_id',
|
||||||
'license_number',
|
'license_number',
|
||||||
|
'arz_total_weight',
|
||||||
|
'arz_usable_mmj',
|
||||||
'harvest_date',
|
'harvest_date',
|
||||||
'package_date',
|
'package_date',
|
||||||
'test_date',
|
'test_date',
|
||||||
|
'launch_date',
|
||||||
|
|
||||||
|
// Display & SEO
|
||||||
'sort_order',
|
'sort_order',
|
||||||
|
'brand_display_order',
|
||||||
'image_path',
|
'image_path',
|
||||||
'meta_title',
|
'meta_title',
|
||||||
'meta_description',
|
'meta_description',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
// Pricing
|
||||||
'wholesale_price' => 'decimal:2',
|
'wholesale_price' => 'decimal:2',
|
||||||
'msrp_price' => 'decimal:2',
|
'msrp' => 'decimal:2',
|
||||||
'cost_per_unit' => 'decimal:2',
|
'cost_per_unit' => 'decimal:2',
|
||||||
|
|
||||||
|
// Measurements
|
||||||
'net_weight' => 'decimal:3',
|
'net_weight' => 'decimal:3',
|
||||||
|
'strain_value' => 'decimal:2',
|
||||||
|
'arz_total_weight' => 'decimal:3',
|
||||||
|
'arz_usable_mmj' => 'decimal:3',
|
||||||
|
|
||||||
|
// Cannabis
|
||||||
'thc_percentage' => 'decimal:2',
|
'thc_percentage' => 'decimal:2',
|
||||||
'cbd_percentage' => 'decimal:2',
|
'cbd_percentage' => 'decimal:2',
|
||||||
'thc_content_mg' => 'decimal:2',
|
'thc_content_mg' => 'decimal:2',
|
||||||
'cbd_content_mg' => 'decimal:2',
|
'cbd_content_mg' => 'decimal:2',
|
||||||
|
|
||||||
|
// Inventory
|
||||||
'quantity_on_hand' => 'integer',
|
'quantity_on_hand' => 'integer',
|
||||||
'quantity_allocated' => 'integer',
|
'quantity_allocated' => 'integer',
|
||||||
'reorder_point' => 'integer',
|
'reorder_point' => 'integer',
|
||||||
|
'min_order_qty' => 'integer',
|
||||||
|
'max_order_qty' => 'integer',
|
||||||
|
'low_stock_threshold' => 'integer',
|
||||||
|
|
||||||
|
// Packaging
|
||||||
'units_per_case' => 'integer',
|
'units_per_case' => 'integer',
|
||||||
|
'cased_qty' => 'integer',
|
||||||
|
'boxed_qty' => 'integer',
|
||||||
|
'brand_display_order' => 'integer',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
|
||||||
|
// Booleans
|
||||||
'is_assembly' => 'boolean',
|
'is_assembly' => 'boolean',
|
||||||
'is_raw_material' => '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_active' => 'boolean',
|
||||||
'is_featured' => 'boolean',
|
'is_featured' => 'boolean',
|
||||||
'sort_order' => 'integer',
|
'show_inventory_to_buyers' => 'boolean',
|
||||||
|
'low_stock_alert_enabled' => 'boolean',
|
||||||
|
'sync_bamboo' => 'boolean',
|
||||||
|
|
||||||
|
// Dates
|
||||||
'harvest_date' => 'date',
|
'harvest_date' => 'date',
|
||||||
'package_date' => 'date',
|
'package_date' => 'date',
|
||||||
'test_date' => 'date',
|
'test_date' => 'date',
|
||||||
|
'launch_date' => 'date',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Audit configuration - exclude timestamps and system-managed fields
|
// Audit configuration - exclude timestamps and system-managed fields
|
||||||
@@ -107,11 +194,26 @@ class Product extends Model implements Auditable
|
|||||||
return $this->belongsTo(Brand::class);
|
return $this->belongsTo(Brand::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function productLine(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProductLine::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function strain(): BelongsTo
|
public function strain(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Strain::class);
|
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
|
public function parent(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Product::class, 'parent_product_id');
|
return $this->belongsTo(Product::class, 'parent_product_id');
|
||||||
@@ -124,7 +226,7 @@ class Product extends Model implements Auditable
|
|||||||
|
|
||||||
public function images(): HasMany
|
public function images(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ProductImage::class)->orderBy('order');
|
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function primaryImage(): HasMany
|
public function primaryImage(): HasMany
|
||||||
|
|||||||
31
app/Models/ProductLine.php
Normal file
31
app/Models/ProductLine.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Models/ProductPackaging.php
Normal file
39
app/Models/ProductPackaging.php
Normal 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
37
app/Models/Unit.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/Traits/FileStorageHelper.php
Normal file
139
app/Traits/FileStorageHelper.php
Normal 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
56
check_blade.js
Normal 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
42
check_blade.php
Normal 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";
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"laravel/reverb": "^1.6",
|
"laravel/reverb": "^1.6",
|
||||||
"laravel/telescope": "*",
|
"laravel/telescope": "*",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"owen-it/laravel-auditing": "^14.0",
|
"owen-it/laravel-auditing": "^14.0",
|
||||||
"predis/predis": "*",
|
"predis/predis": "*",
|
||||||
"rahulhaque/laravel-filepond": "*",
|
"rahulhaque/laravel-filepond": "*",
|
||||||
|
|||||||
274
composer.lock
generated
274
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "8862e7bdefe037022f988b1c4ff4b298",
|
"content-hash": "6045e661b5737a9c8dd40aeeaf8c9897",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -72,6 +72,157 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-07-30T15:45:57+00:00"
|
"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",
|
"name": "barryvdh/laravel-dompdf",
|
||||||
"version": "v3.1.1",
|
"version": "v3.1.1",
|
||||||
@@ -3431,6 +3582,61 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-10-20T15:35:26+00:00"
|
"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",
|
"name": "league/flysystem-local",
|
||||||
"version": "3.30.0",
|
"version": "3.30.0",
|
||||||
@@ -4038,6 +4244,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-24T10:02:05+00:00"
|
"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",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.10.3",
|
"version": "3.10.3",
|
||||||
|
|||||||
37
database/migrations/2025_11_05_060900_create_units_table.php
Normal file
37
database/migrations/2025_11_05_060900_create_units_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
41
database/seeders/ProductLineSeeder.php
Normal file
41
database/seeders/ProductLineSeeder.php
Normal 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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
database/seeders/ProductPackagingSeeder.php
Normal file
81
database/seeders/ProductPackagingSeeder.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
16
database/seeders/StrainClassificationSeeder.php
Normal file
16
database/seeders/StrainClassificationSeeder.php
Normal 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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
83
database/seeders/UnitSeeder.php
Normal file
83
database/seeders/UnitSeeder.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view("landing");
|
return view('landing');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/{name}', function ($name) {
|
Route::get('/{name}', function ($name) {
|
||||||
if (view()->exists($name)) {
|
if (view()->exists($name)) {
|
||||||
return view($name);
|
return view($name);
|
||||||
}
|
}
|
||||||
return view("not-found");
|
|
||||||
|
return view('not-found');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,22 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@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>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -13,6 +13,22 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@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>
|
</head>
|
||||||
<body class="font-sans antialiased bg-base-100">
|
<body class="font-sans antialiased bg-base-100">
|
||||||
<script>
|
<script>
|
||||||
@@ -44,7 +60,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<footer class="text-center text-sm text-gray-400 py-4 bg-base-100">
|
<footer class="text-center text-sm text-gray-400 py-4 bg-base-100">
|
||||||
<p>
|
<p>
|
||||||
© {{ date('Y') }} cannabrands |
|
© {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
|
||||||
@if($appVersion === 'dev')
|
@if($appVersion === 'dev')
|
||||||
<span class="text-yellow-500 font-semibold">DEV</span>
|
<span class="text-yellow-500 font-semibold">DEV</span>
|
||||||
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
|
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="text-center text-sm text-base-content/secondary py-4 bg-base-100">
|
<footer class="text-center text-sm text-base-content/secondary py-4 bg-base-100">
|
||||||
<p>
|
<p>
|
||||||
© {{ date('Y') }} cannabrands |
|
© {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
|
||||||
@if($appVersion === 'dev')
|
@if($appVersion === 'dev')
|
||||||
<span class="text-yellow-500 font-semibold">DEV</span>
|
<span class="text-yellow-500 font-semibold">DEV</span>
|
||||||
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
|
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1530
resources/views/seller/products/edit.blade.php.backup
Normal file
1530
resources/views/seller/products/edit.blade.php.backup
Normal file
File diff suppressed because it is too large
Load Diff
1593
resources/views/seller/products/edit1.blade.php
Normal file
1593
resources/views/seller/products/edit1.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,133 +23,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
<!-- Tabs Navigation -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<div class="card bg-base-100 shadow-sm">
|
<div role="tablist" class="tabs tabs-boxed bg-base-200 p-1">
|
||||||
<div class="card-body p-4">
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Active Products" checked />
|
||||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-6 gap-3">
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<input type="text" name="search" placeholder="Search products..." class="input input-bordered input-sm w-full" value="{{ request('search') }}" />
|
@include('seller.products.partials.active-products')
|
||||||
<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>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Products Table -->
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Inventory" />
|
||||||
<div class="mt-6">
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<div class="card bg-base-100 shadow overflow-visible">
|
@include('seller.products.partials.inventory-tab')
|
||||||
<div class="card-body p-0 overflow-visible">
|
</div>
|
||||||
@if($products->count() > 0)
|
|
||||||
<div class="overflow-x-auto overflow-y-visible">
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Archived" />
|
||||||
<table class="table table-zebra">
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<thead>
|
@include('seller.products.partials.archived-tab')
|
||||||
<tr>
|
</div>
|
||||||
<th>Image</th>
|
|
||||||
<th>SKU</th>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Listings" />
|
||||||
<th>Name</th>
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<th>Brand</th>
|
@include('seller.products.partials.listings-tab')
|
||||||
<th>Type</th>
|
</div>
|
||||||
<th>Price</th>
|
|
||||||
<th>Stock</th>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Batch Management" />
|
||||||
<th>Status</th>
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<th class="w-12"></th>
|
@include('seller.products.partials.batch-management-tab')
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Custom Menus" />
|
||||||
@foreach($products as $product)
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<tr>
|
@include('seller.products.partials.custom-menus-tab')
|
||||||
<td>
|
</div>
|
||||||
@php
|
|
||||||
$img = $product->images()->where('is_primary', true)->first() ?? $product->images()->first();
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Promotions" />
|
||||||
@endphp
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
@if($img)
|
@include('seller.products.partials.promotions-tab')
|
||||||
<div class="avatar"><div class="mask mask-squircle w-10 h-10"><img src="{{ asset('storage/' . $img->path) }}" /></div></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>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Product Lines" />
|
||||||
@endif
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
</td>
|
@include('seller.products.partials.product-lines-tab')
|
||||||
<td><span class="font-mono text-xs">{{ $product->sku }}</span></td>
|
</div>
|
||||||
<td><div class="font-medium">{{ $product->name }}</div></td>
|
|
||||||
<td><span class="badge badge-outline badge-sm whitespace-nowrap">{{ $product->brand->name }}</span></td>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Templates" />
|
||||||
<td><span class="badge badge-primary badge-sm">{{ ucfirst($product->type) }}</span></td>
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<td>
|
@include('seller.products.partials.templates-tab')
|
||||||
<div class="flex items-baseline gap-1 whitespace-nowrap">
|
</div>
|
||||||
<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>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Bulk Add/Edit" />
|
||||||
</div>
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
</td>
|
@include('seller.products.partials.bulk-add-edit-tab')
|
||||||
<td><span class="font-medium {{ $product->quantity_on_hand <= ($product->reorder_point ?? 0) ? 'text-error' : 'text-success' }}">{{ $product->quantity_on_hand ?? 0 }}</span></td>
|
</div>
|
||||||
<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>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Strains" />
|
||||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<button tabindex="0" class="btn btn-ghost btn-xs">
|
@include('seller.products.partials.strains-tab')
|
||||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
</div>
|
||||||
</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' : '' }}">
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Product Packaging" />
|
||||||
<li>
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->id]) }}">
|
@include('seller.products.partials.product-packaging-tab')
|
||||||
<span class="icon-[lucide--pencil] size-4"></span>
|
</div>
|
||||||
Edit
|
|
||||||
</a>
|
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="FAQ's" />
|
||||||
</li>
|
<div role="tabpanel" class="tab-content pt-6">
|
||||||
<li>
|
@include('seller.products.partials.faqs-tab')
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
68
resources/views/storage-test.blade.php
Normal file
68
resources/views/storage-test.blade.php
Normal 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>
|
||||||
@@ -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::get('/create', [\App\Http\Controllers\Seller\ProductController::class, 'create'])->name('create');
|
||||||
Route::post('/', [\App\Http\Controllers\Seller\ProductController::class, 'store'])->name('store');
|
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}/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::put('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'update'])->name('update');
|
||||||
Route::delete('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'destroy'])->name('destroy');
|
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::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');
|
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)
|
// Component Management (business-scoped)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\StorageTestController;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -198,3 +199,9 @@ Route::prefix('api')->group(function () {
|
|||||||
->name('api.check-email')
|
->name('api.check-email')
|
||||||
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
|
->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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user