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(curl:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(docker update:*)"
|
||||
"Bash(docker update:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(php artisan:*)",
|
||||
"Bash(php check_blade.php:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
15
.env.example
15
.env.example
@@ -77,10 +77,25 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# AWS/MinIO S3 Storage Configuration
|
||||
# Local development: Use FILESYSTEM_DISK=public (default)
|
||||
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_ENDPOINT=
|
||||
AWS_URL=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# Production MinIO Configuration (example):
|
||||
# FILESYSTEM_DISK=s3
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -58,3 +58,4 @@ core.*
|
||||
!resources/**/*.png
|
||||
!resources/**/*.jpg
|
||||
!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)
|
||||
✅ Use Laravel Schema builder or conditional PHP code
|
||||
|
||||
### 7. Styling - DaisyUI/Tailwind Only
|
||||
❌ **NEVER use inline `style=""` attributes** in Blade templates
|
||||
✅ **ALWAYS use DaisyUI/Tailwind utility classes**
|
||||
**Why:** Consistency, maintainability, theme switching, and better performance
|
||||
|
||||
**Correct patterns:**
|
||||
- Colors: Use `bg-primary`, `text-primary`, `bg-success`, etc. (defined in `resources/css/app.css`)
|
||||
- Spacing: Use `p-4`, `m-2`, `gap-3` (Tailwind utilities)
|
||||
- Layout: Use `flex`, `grid`, `items-center` (Tailwind utilities)
|
||||
- Custom colors: Add to `resources/css/app.css` theme variables, NOT inline
|
||||
|
||||
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack by Area
|
||||
@@ -106,4 +119,5 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
✅ Use Eloquent (never raw SQL)
|
||||
✅ Protect routes with middleware
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
|
||||
258
PRODUCT2_INSTRUCTIONS.md
Normal file
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\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductLine;
|
||||
use App\Models\ProductPackaging;
|
||||
use App\Models\Strain;
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -69,7 +73,13 @@ class ProductController extends Controller
|
||||
// Get all brands for filter dropdown
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'brands'));
|
||||
// Get product lines for this business with products count
|
||||
$productLines = ProductLine::where('business_id', $business->id)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'brands', 'productLines'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,23 +158,101 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function edit(Business $business, Product $product)
|
||||
{
|
||||
// Eager load relationships
|
||||
$product->load(['brand', 'images']);
|
||||
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Verify product belongs to this business
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
// Prepare dropdown data
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$strains = Strain::all();
|
||||
$packagings = ProductPackaging::all();
|
||||
$units = Unit::all();
|
||||
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
||||
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
// Product type options (for category dropdown)
|
||||
$productTypes = [
|
||||
'flower' => 'Flower',
|
||||
'preroll' => 'Pre-Roll',
|
||||
'vape' => 'Vape',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// Load audits with pagination (10 per page) for the audit history tab
|
||||
$audits = $product->audits()
|
||||
->with('user')
|
||||
->latest()
|
||||
->paginate(10);
|
||||
// Status options
|
||||
$statusOptions = [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
return view('seller.products.edit', compact('business', 'product', 'brands', 'audits'));
|
||||
return view('seller.products.edit', compact(
|
||||
'business',
|
||||
'product',
|
||||
'brands',
|
||||
'strains',
|
||||
'packagings',
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified product (edit1 - top header layout)
|
||||
*/
|
||||
public function edit1(Business $business, Product $product)
|
||||
{
|
||||
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
|
||||
->findOrFail($product->id);
|
||||
|
||||
// Prepare dropdown data
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$strains = Strain::all();
|
||||
$packagings = ProductPackaging::all();
|
||||
$units = Unit::all();
|
||||
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
|
||||
|
||||
// Product type options (for category dropdown)
|
||||
$productTypes = [
|
||||
'flower' => 'Flower',
|
||||
'preroll' => 'Pre-Roll',
|
||||
'vape' => 'Vape',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'other' => 'Other',
|
||||
];
|
||||
|
||||
// Status options
|
||||
$statusOptions = [
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
return view('seller.products.edit1', compact(
|
||||
'business',
|
||||
'product',
|
||||
'brands',
|
||||
'strains',
|
||||
'packagings',
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,58 +260,150 @@ class ProductController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Business $business, Product $product)
|
||||
{
|
||||
// Verify product belongs to this business
|
||||
if (! $product->belongsToBusiness($business)) {
|
||||
abort(403, 'This product does not belong to your business');
|
||||
}
|
||||
|
||||
// Comprehensive validation
|
||||
$validated = $request->validate([
|
||||
// Basic Information
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'sku' => 'required|string|max:100|unique:products,sku,'.$product->id,
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'wholesale_price' => 'required|numeric|min:0',
|
||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'sku' => 'required|string|max:100',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'type' => 'nullable|string',
|
||||
'product_line_id' => 'nullable|exists:product_lines,id',
|
||||
'unit_id' => 'required|exists:units,id',
|
||||
'sell_multiples' => 'nullable|boolean',
|
||||
'fractional_quantities' => 'nullable|boolean',
|
||||
'allow_sample' => 'nullable|boolean',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_featured' => 'nullable|boolean',
|
||||
|
||||
// Inventory - now includes threshold type
|
||||
'status' => 'required|string',
|
||||
'launch_date' => 'nullable|date',
|
||||
'quantity_on_hand' => 'nullable|integer|min:0',
|
||||
'quantity_allocated' => 'nullable|integer|min:0',
|
||||
'sync_bamboo' => 'nullable|boolean',
|
||||
'low_stock_threshold' => 'nullable|numeric|min:0',
|
||||
'low_stock_threshold_type' => 'nullable|string|in:qty,percent',
|
||||
'low_stock_alert_enabled' => 'nullable|boolean',
|
||||
'is_assembly' => 'nullable|boolean',
|
||||
'show_inventory_to_buyers' => 'nullable|boolean',
|
||||
'packaging_id' => 'nullable|exists:product_packagings,id',
|
||||
|
||||
// Pricing & Units
|
||||
'cost_per_unit' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
'msrp' => 'nullable|numeric|min:0',
|
||||
'net_weight' => 'nullable|numeric|min:0',
|
||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
||||
'units_per_case' => 'nullable|integer|min:1',
|
||||
'weight_unit' => 'nullable|string|max:20',
|
||||
'units_per_case' => 'nullable|integer|min:0',
|
||||
'cased_qty' => 'nullable|integer|min:0',
|
||||
'boxed_qty' => 'nullable|integer|min:0',
|
||||
'min_order_qty' => 'nullable|integer|min:0',
|
||||
'max_order_qty' => 'nullable|integer|min:0',
|
||||
'is_case' => 'nullable|boolean',
|
||||
'is_box' => 'nullable|boolean',
|
||||
'has_varieties' => 'nullable|boolean',
|
||||
|
||||
// Cannabis Information
|
||||
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'strain_id' => 'nullable|exists:strains,id',
|
||||
'thc_content_mg' => 'nullable|numeric|min:0',
|
||||
'cbd_content_mg' => 'nullable|numeric|min:0',
|
||||
'strain_value' => 'nullable|numeric|min:0',
|
||||
'ingredients' => 'nullable|string',
|
||||
'effects' => 'nullable|string',
|
||||
'dosage_guidelines' => 'nullable|string',
|
||||
|
||||
// Arizona Compliance
|
||||
'arz_total_weight' => 'nullable|numeric|min:0',
|
||||
'arz_usable_mmj' => 'nullable|numeric|min:0',
|
||||
'metrc_id' => 'nullable|string|max:255',
|
||||
|
||||
// Compliance & Tracking
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'harvest_date' => 'nullable|date',
|
||||
'package_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
|
||||
// Product Details
|
||||
'description' => 'nullable|string|max:100',
|
||||
'long_description' => 'nullable|string',
|
||||
'product_link' => 'nullable|url|max:255',
|
||||
'creatives_json' => 'nullable|json',
|
||||
|
||||
// Advanced Settings
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'is_fpr' => 'nullable|boolean',
|
||||
'is_raw_material' => 'nullable|boolean',
|
||||
'brand_display_order' => 'nullable|integer|min:0',
|
||||
'parent_product_id' => 'nullable|exists:products,id',
|
||||
'category' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// Verify new brand belongs to this business
|
||||
$brand = Brand::forBusiness($business)
|
||||
->findOrFail($validated['brand_id']);
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $product->name) {
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
}
|
||||
|
||||
// Handle checkbox fields - set to false if not present in request
|
||||
// Convert checkboxes to boolean
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
$validated['sell_multiples'] = $request->has('sell_multiples');
|
||||
$validated['fractional_quantities'] = $request->has('fractional_quantities');
|
||||
$validated['allow_sample'] = $request->has('allow_sample');
|
||||
$validated['is_case'] = $request->has('is_case');
|
||||
$validated['is_box'] = $request->has('is_box');
|
||||
$validated['has_varieties'] = $request->has('has_varieties');
|
||||
$validated['sync_bamboo'] = $request->has('sync_bamboo');
|
||||
$validated['low_stock_alert_enabled'] = $request->has('low_stock_alert_enabled');
|
||||
$validated['is_assembly'] = $request->has('is_assembly');
|
||||
$validated['show_inventory_to_buyers'] = $request->has('show_inventory_to_buyers');
|
||||
$validated['is_sellable'] = $request->has('is_sellable');
|
||||
$validated['is_fpr'] = $request->has('is_fpr');
|
||||
$validated['is_raw_material'] = $request->has('is_raw_material');
|
||||
|
||||
// Store creatives JSON
|
||||
if (isset($validated['creatives_json'])) {
|
||||
$validated['creatives'] = $validated['creatives_json'];
|
||||
unset($validated['creatives_json']);
|
||||
}
|
||||
|
||||
// CRITICAL BUSINESS ISOLATION: Verify brand belongs to the business
|
||||
$brand = Brand::where('id', $validated['brand_id'])
|
||||
->where('business_id', $business->id)
|
||||
->firstOrFail();
|
||||
|
||||
// CRITICAL BUSINESS ISOLATION: Ensure the product belongs to this business through brand relationship
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->findOrFail($product->id);
|
||||
|
||||
// BUSINESS RULE: Only one active product per brand
|
||||
if ($request->has('is_active') && $request->boolean('is_active')) {
|
||||
$existingActiveProduct = Product::where('brand_id', $validated['brand_id'])
|
||||
->where('is_active', true)
|
||||
->where('id', '!=', $product->id)
|
||||
->first();
|
||||
|
||||
if ($existingActiveProduct) {
|
||||
// Check if user wants to force-activate this product
|
||||
if ($request->has('force_activate') && $request->boolean('force_activate')) {
|
||||
// Deactivate the existing active product
|
||||
$existingActiveProduct->update(['is_active' => false]);
|
||||
} else {
|
||||
// Show error with option to force activate
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->with('existing_active_product', $existingActiveProduct)
|
||||
->withErrors(['is_active' => "Only one product can be active per brand at a time. '{$existingActiveProduct->name}' (SKU: {$existingActiveProduct->sku}) is currently active."]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update product
|
||||
$product->update($validated);
|
||||
|
||||
// Handle new image uploads if present
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $product->images()->count() === 0 && $index === 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', "Product '{$product->name}' updated successfully!");
|
||||
return redirect()
|
||||
->route('seller.business.products.edit', [$business->slug, $product->id])
|
||||
->with('success', 'Product updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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;
|
||||
|
||||
protected $fillable = [
|
||||
// Foreign Keys
|
||||
'brand_id',
|
||||
'strain_id',
|
||||
'parent_product_id',
|
||||
'packaging_id',
|
||||
'unit_id',
|
||||
|
||||
// Product Identity
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'barcode',
|
||||
'description',
|
||||
'long_description',
|
||||
|
||||
// Product Type & Classification
|
||||
'type',
|
||||
'category',
|
||||
'product_line',
|
||||
'product_link',
|
||||
'creatives',
|
||||
|
||||
// BOM Flags
|
||||
'is_assembly',
|
||||
'is_raw_material',
|
||||
|
||||
// Configuration Flags
|
||||
'has_varieties',
|
||||
'sell_multiples',
|
||||
'fractional_quantities',
|
||||
'allow_sample',
|
||||
'is_fpr',
|
||||
'is_sellable',
|
||||
|
||||
// Pricing
|
||||
'wholesale_price',
|
||||
'msrp_price',
|
||||
'msrp',
|
||||
'cost_per_unit',
|
||||
'price_unit',
|
||||
|
||||
// Packaging & Units
|
||||
'net_weight',
|
||||
'weight_unit',
|
||||
'units_per_case',
|
||||
'is_case',
|
||||
'cased_qty',
|
||||
'is_box',
|
||||
'boxed_qty',
|
||||
|
||||
// Cannabis-specific
|
||||
'thc_percentage',
|
||||
'cbd_percentage',
|
||||
'thc_content_mg',
|
||||
'cbd_content_mg',
|
||||
'strain_value',
|
||||
'ingredients',
|
||||
'effects',
|
||||
'dosage_guidelines',
|
||||
|
||||
// Inventory & Status
|
||||
'quantity_on_hand',
|
||||
'quantity_allocated',
|
||||
'reorder_point',
|
||||
'min_order_qty',
|
||||
'max_order_qty',
|
||||
'low_stock_threshold',
|
||||
'low_stock_alert_enabled',
|
||||
'sync_bamboo',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'show_inventory_to_buyers',
|
||||
'status',
|
||||
|
||||
// Compliance & Tracking
|
||||
'metrc_id',
|
||||
'license_number',
|
||||
'arz_total_weight',
|
||||
'arz_usable_mmj',
|
||||
'harvest_date',
|
||||
'package_date',
|
||||
'test_date',
|
||||
'launch_date',
|
||||
|
||||
// Display & SEO
|
||||
'sort_order',
|
||||
'brand_display_order',
|
||||
'image_path',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
// Pricing
|
||||
'wholesale_price' => 'decimal:2',
|
||||
'msrp_price' => 'decimal:2',
|
||||
'msrp' => 'decimal:2',
|
||||
'cost_per_unit' => 'decimal:2',
|
||||
|
||||
// Measurements
|
||||
'net_weight' => 'decimal:3',
|
||||
'strain_value' => 'decimal:2',
|
||||
'arz_total_weight' => 'decimal:3',
|
||||
'arz_usable_mmj' => 'decimal:3',
|
||||
|
||||
// Cannabis
|
||||
'thc_percentage' => 'decimal:2',
|
||||
'cbd_percentage' => 'decimal:2',
|
||||
'thc_content_mg' => 'decimal:2',
|
||||
'cbd_content_mg' => 'decimal:2',
|
||||
|
||||
// Inventory
|
||||
'quantity_on_hand' => 'integer',
|
||||
'quantity_allocated' => 'integer',
|
||||
'reorder_point' => 'integer',
|
||||
'min_order_qty' => 'integer',
|
||||
'max_order_qty' => 'integer',
|
||||
'low_stock_threshold' => 'integer',
|
||||
|
||||
// Packaging
|
||||
'units_per_case' => 'integer',
|
||||
'cased_qty' => 'integer',
|
||||
'boxed_qty' => 'integer',
|
||||
'brand_display_order' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
|
||||
// Booleans
|
||||
'is_assembly' => 'boolean',
|
||||
'is_raw_material' => 'boolean',
|
||||
'has_varieties' => 'boolean',
|
||||
'sell_multiples' => 'boolean',
|
||||
'fractional_quantities' => 'boolean',
|
||||
'allow_sample' => 'boolean',
|
||||
'is_fpr' => 'boolean',
|
||||
'is_sellable' => 'boolean',
|
||||
'is_case' => 'boolean',
|
||||
'is_box' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'show_inventory_to_buyers' => 'boolean',
|
||||
'low_stock_alert_enabled' => 'boolean',
|
||||
'sync_bamboo' => 'boolean',
|
||||
|
||||
// Dates
|
||||
'harvest_date' => 'date',
|
||||
'package_date' => 'date',
|
||||
'test_date' => 'date',
|
||||
'launch_date' => 'date',
|
||||
];
|
||||
|
||||
// Audit configuration - exclude timestamps and system-managed fields
|
||||
@@ -107,11 +194,26 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function productLine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductLine::class);
|
||||
}
|
||||
|
||||
public function strain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Strain::class);
|
||||
}
|
||||
|
||||
public function packaging(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductPackaging::class, 'packaging_id');
|
||||
}
|
||||
|
||||
public function unit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'parent_product_id');
|
||||
@@ -124,7 +226,7 @@ class Product extends Model implements Auditable
|
||||
|
||||
public function images(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductImage::class)->orderBy('order');
|
||||
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function primaryImage(): HasMany
|
||||
|
||||
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/telescope": "*",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"owen-it/laravel-auditing": "^14.0",
|
||||
"predis/predis": "*",
|
||||
"rahulhaque/laravel-filepond": "*",
|
||||
|
||||
274
composer.lock
generated
274
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "8862e7bdefe037022f988b1c4ff4b298",
|
||||
"content-hash": "6045e661b5737a9c8dd40aeeaf8c9897",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -72,6 +72,157 @@
|
||||
},
|
||||
"time": "2025-07-30T15:45:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.359.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5",
|
||||
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.8.0",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.4.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-pcntl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"symfony/filesystem": "^v6.4.0 || ^v7.1.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "http://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "http://aws.amazon.com/sdkforphp",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.6"
|
||||
},
|
||||
"time": "2025-11-05T19:08:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.1",
|
||||
@@ -3431,6 +3582,61 @@
|
||||
},
|
||||
"time": "2025-10-20T15:35:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.30.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "d286e896083bed3190574b8b088b557b59eb66f5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5",
|
||||
"reference": "d286e896083bed3190574b8b088b557b59eb66f5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.295.10",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1"
|
||||
},
|
||||
"time": "2025-10-20T15:27:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.30.0",
|
||||
@@ -4038,6 +4244,72 @@
|
||||
],
|
||||
"time": "2025-03-24T10:02:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.33"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
|
||||
},
|
||||
"time": "2024-09-04T18:46:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.10.3",
|
||||
|
||||
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;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view("landing");
|
||||
return view('landing');
|
||||
});
|
||||
|
||||
Route::get('/{name}', function ($name) {
|
||||
if (view()->exists($name)) {
|
||||
return view($name);
|
||||
}
|
||||
return view("not-found");
|
||||
|
||||
return view('not-found');
|
||||
});
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<!-- Remove spinner arrows from number inputs globally -->
|
||||
<style>
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<script>
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<!-- Remove spinner arrows from number inputs globally -->
|
||||
<style>
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-base-100">
|
||||
<script>
|
||||
@@ -44,7 +60,7 @@
|
||||
</main>
|
||||
<footer class="text-center text-sm text-gray-400 py-4 bg-base-100">
|
||||
<p>
|
||||
© {{ date('Y') }} cannabrands |
|
||||
© {{ date('Y') }} <a href="https://creationshop.io" target="_blank" class="hover:text-primary transition-colors">Creationshop, LLC</a> |
|
||||
@if($appVersion === 'dev')
|
||||
<span class="text-yellow-500 font-semibold">DEV</span>
|
||||
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
<footer class="text-center text-sm text-base-content/secondary py-4 bg-base-100">
|
||||
<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')
|
||||
<span class="text-yellow-500 font-semibold">DEV</span>
|
||||
<span class="font-mono text-xs">sha-{{ $appCommit }}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
<input type="text" name="search" placeholder="Search products..." class="input input-bordered input-sm w-full" value="{{ request('search') }}" />
|
||||
<select name="type" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Types</option>
|
||||
<option value="flower" {{ request('type') === 'flower' ? 'selected' : '' }}>Flower</option>
|
||||
<option value="pre-roll" {{ request('type') === 'pre-roll' ? 'selected' : '' }}>Pre-Roll</option>
|
||||
<option value="concentrate" {{ request('type') === 'concentrate' ? 'selected' : '' }}>Concentrate</option>
|
||||
<option value="edible" {{ request('type') === 'edible' ? 'selected' : '' }}>Edible</option>
|
||||
</select>
|
||||
@if(!session('selected_brand_id') && $brands->count() > 1)
|
||||
<select name="brand_id" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Brands</option>
|
||||
@foreach($brands as $brand)
|
||||
<option value="{{ $brand->id }}" {{ request('brand_id') == $brand->id ? 'selected' : '' }}>{{ $brand->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@endif
|
||||
<select name="status" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Status</option>
|
||||
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>Active</option>
|
||||
<option value="inactive" {{ request('status') === 'inactive' ? 'selected' : '' }}>Inactive</option>
|
||||
</select>
|
||||
<select name="stock" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Stock</option>
|
||||
<option value="low" {{ request('stock') === 'low' ? 'selected' : '' }}>Low Stock</option>
|
||||
</select>
|
||||
<div class="flex gap-2 w-full">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-1">Filter</button>
|
||||
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
<div role="tablist" class="tabs tabs-boxed bg-base-200 p-1">
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Active Products" checked />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.active-products')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Table -->
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow overflow-visible">
|
||||
<div class="card-body p-0 overflow-visible">
|
||||
@if($products->count() > 0)
|
||||
<div class="overflow-x-auto overflow-y-visible">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>SKU</th>
|
||||
<th>Name</th>
|
||||
<th>Brand</th>
|
||||
<th>Type</th>
|
||||
<th>Price</th>
|
||||
<th>Stock</th>
|
||||
<th>Status</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($products as $product)
|
||||
<tr>
|
||||
<td>
|
||||
@php
|
||||
$img = $product->images()->where('is_primary', true)->first() ?? $product->images()->first();
|
||||
@endphp
|
||||
@if($img)
|
||||
<div class="avatar"><div class="mask mask-squircle w-10 h-10"><img src="{{ asset('storage/' . $img->path) }}" /></div></div>
|
||||
@else
|
||||
<div class="avatar placeholder"><div class="bg-neutral text-neutral-content mask mask-squircle w-10"><span class="icon-[lucide--box] size-5"></span></div></div>
|
||||
@endif
|
||||
</td>
|
||||
<td><span class="font-mono text-xs">{{ $product->sku }}</span></td>
|
||||
<td><div class="font-medium">{{ $product->name }}</div></td>
|
||||
<td><span class="badge badge-outline badge-sm whitespace-nowrap">{{ $product->brand->name }}</span></td>
|
||||
<td><span class="badge badge-primary badge-sm">{{ ucfirst($product->type) }}</span></td>
|
||||
<td>
|
||||
<div class="flex items-baseline gap-1 whitespace-nowrap">
|
||||
<span class="font-semibold">${{ number_format($product->wholesale_price, 2) }}</span>
|
||||
<span class="text-xs text-base-content/60">/ {{ $product->price_unit === 'each' ? 'ea' : ($product->price_unit === 'gram' ? 'g' : $product->price_unit) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="font-medium {{ $product->quantity_on_hand <= ($product->reorder_point ?? 0) ? 'text-error' : 'text-success' }}">{{ $product->quantity_on_hand ?? 0 }}</span></td>
|
||||
<td>@if($product->is_active)<span class="badge badge-success badge-sm">Active</span>@else<span class="badge badge-warning badge-sm">Inactive</span>@endif</td>
|
||||
<td>
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->id]) }}">
|
||||
<span class="icon-[lucide--pencil] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form action="{{ route('seller.business.products.destroy', [$business->slug, $product->id]) }}" method="POST" onsubmit="return confirm('Delete this product?')" class="w-full">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-error w-full text-left">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-4 border-t border-base-300">{{ $products->links() }}</div>
|
||||
@else
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--box] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No products found</h3>
|
||||
<p class="text-base-content/60 mb-4">Get started by creating your first product</p>
|
||||
<a href="{{ route('seller.business.products.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-5"></span>
|
||||
Add Product
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Inventory" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.inventory-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Archived" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.archived-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Listings" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.listings-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Batch Management" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.batch-management-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Custom Menus" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.custom-menus-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Promotions" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.promotions-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Product Lines" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.product-lines-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Templates" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.templates-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Bulk Add/Edit" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.bulk-add-edit-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Strains" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.strains-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Product Packaging" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.product-packaging-tab')
|
||||
</div>
|
||||
|
||||
<input type="radio" name="product_management_tabs" role="tab" class="tab whitespace-nowrap" aria-label="FAQ's" />
|
||||
<div role="tabpanel" class="tab-content pt-6">
|
||||
@include('seller.products.partials.faqs-tab')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@endsection
|
||||
|
||||
@@ -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::post('/', [\App\Http\Controllers\Seller\ProductController::class, 'store'])->name('store');
|
||||
Route::get('/{product}/edit', [\App\Http\Controllers\Seller\ProductController::class, 'edit'])->name('edit');
|
||||
Route::get('/{product}/edit1', [\App\Http\Controllers\Seller\ProductController::class, 'edit1'])->name('edit1');
|
||||
Route::put('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'update'])->name('update');
|
||||
Route::delete('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'destroy'])->name('destroy');
|
||||
|
||||
@@ -195,6 +196,21 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::delete('/component/{component}', [\App\Http\Controllers\Seller\Product\BomController::class, 'detach'])->name('detach');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Seller\Product\BomController::class, 'reorder'])->name('reorder');
|
||||
});
|
||||
|
||||
// Product Image Management
|
||||
Route::prefix('{product}/images')->name('images.')->group(function () {
|
||||
Route::post('/upload', [\App\Http\Controllers\Seller\ProductImageController::class, 'upload'])->name('upload');
|
||||
Route::delete('/{image}', [\App\Http\Controllers\Seller\ProductImageController::class, 'delete'])->name('delete');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Seller\ProductImageController::class, 'reorder'])->name('reorder');
|
||||
Route::post('/{image}/set-primary', [\App\Http\Controllers\Seller\ProductImageController::class, 'setPrimary'])->name('set-primary');
|
||||
});
|
||||
});
|
||||
|
||||
// Product Lines Management (business-scoped)
|
||||
Route::prefix('product-lines')->name('product-lines.')->group(function () {
|
||||
Route::post('/', [\App\Http\Controllers\Seller\ProductLineController::class, 'store'])->name('store');
|
||||
Route::put('/{productLine}', [\App\Http\Controllers\Seller\ProductLineController::class, 'update'])->name('update');
|
||||
Route::delete('/{productLine}', [\App\Http\Controllers\Seller\ProductLineController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Component Management (business-scoped)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\StorageTestController;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -198,3 +199,9 @@ Route::prefix('api')->group(function () {
|
||||
->name('api.check-email')
|
||||
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
|
||||
});
|
||||
|
||||
// Storage Test Routes (Development/Testing Only)
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/storage-test', [StorageTestController::class, 'form'])->name('storage.test.form');
|
||||
Route::post('/storage-test', [StorageTestController::class, 'test'])->name('storage.test');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user