Compare commits
16 Commits
docs/add-f
...
feature/fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb39392d90 | ||
|
|
05f8e334cb | ||
|
|
3ef8bc6986 | ||
|
|
e20fcb0830 | ||
|
|
08ccaaf568 | ||
|
|
f9c0a19027 | ||
|
|
96bc76ec7c | ||
|
|
b910aca32d | ||
|
|
6c5a9ce0c1 | ||
|
|
b0d3377bdd | ||
|
|
32acc62b4b | ||
|
|
4cb157a09b | ||
|
|
2106828813 | ||
|
|
098d358937 | ||
|
|
82b5a44a56 | ||
|
|
a679008f23 |
121
app/Http/Controllers/Buyer/FavoriteController.php
Normal file
121
app/Http/Controllers/Buyer/FavoriteController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Favorite;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the user's favorite products
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Get user's favorite products with relationships
|
||||
$query = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->whereHas('favorites', function ($q) use ($user) {
|
||||
$q->where('user_id', $user->id);
|
||||
})
|
||||
->active();
|
||||
|
||||
// Search filter (name, SKU, description)
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('sku', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($brandId = $request->input('brand_id')) {
|
||||
$query->where('brand_id', $brandId);
|
||||
}
|
||||
|
||||
// Strain type filter
|
||||
if ($strainType = $request->input('strain_type')) {
|
||||
$query->whereHas('strain', function ($q) use ($strainType) {
|
||||
$q->where('type', $strainType);
|
||||
});
|
||||
}
|
||||
|
||||
// Price range filter
|
||||
if ($priceMin = $request->input('price_min')) {
|
||||
$query->where('wholesale_price', '>=', $priceMin);
|
||||
}
|
||||
if ($priceMax = $request->input('price_max')) {
|
||||
$query->where('wholesale_price', '<=', $priceMax);
|
||||
}
|
||||
|
||||
// In stock filter
|
||||
if ($request->input('in_stock')) {
|
||||
$query->where('quantity_on_hand', '>', 0);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sort = $request->input('sort', 'newest');
|
||||
match ($sort) {
|
||||
'name_asc' => $query->orderBy('name', 'asc'),
|
||||
'name_desc' => $query->orderBy('name', 'desc'),
|
||||
'price_asc' => $query->orderBy('wholesale_price', 'asc'),
|
||||
'price_desc' => $query->orderBy('wholesale_price', 'desc'),
|
||||
'newest' => $query->latest(),
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
// Paginate results
|
||||
$products = $query->paginate(12)->withQueryString();
|
||||
|
||||
// Get all active brands for filters (only brands with favorited products)
|
||||
$brands = Brand::active()
|
||||
->whereHas('products.favorites', function ($q) use ($user) {
|
||||
$q->where('user_id', $user->id);
|
||||
})
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('buyer.favorites.index', compact('products', 'brands'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status for a product
|
||||
*/
|
||||
public function toggle(Request $request, Product $product)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$favorite = Favorite::where('user_id', $user->id)
|
||||
->where('product_id', $product->id)
|
||||
->first();
|
||||
|
||||
if ($favorite) {
|
||||
// Remove from favorites
|
||||
$favorite->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'favorited' => false,
|
||||
'message' => 'Product removed from favorites',
|
||||
]);
|
||||
} else {
|
||||
// Add to favorites
|
||||
Favorite::create([
|
||||
'user_id' => $user->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'favorited' => true,
|
||||
'message' => 'Product added to favorites',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
321
app/Http/Controllers/Seller/BrandController.php
Normal file
321
app/Http/Controllers/Seller/BrandController.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BrandController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of brands for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all brands for this business
|
||||
$brands = Brand::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Prepare brands data for JavaScript (client-side search/sort/pagination)
|
||||
$brandsData = $brands->map(function ($brand) {
|
||||
return [
|
||||
'id' => $brand->id,
|
||||
'slug' => $brand->slug,
|
||||
'name' => $brand->name,
|
||||
'tagline' => $brand->tagline,
|
||||
'sku_prefix' => $brand->sku_prefix,
|
||||
'is_active' => $brand->is_active,
|
||||
'is_public' => $brand->is_public,
|
||||
'is_featured' => $brand->is_featured,
|
||||
'sort_order' => $brand->sort_order,
|
||||
'products_count' => $brand->products->count(),
|
||||
'logo_url' => $brand->hasLogo() ? $brand->getLogoUrl() : null,
|
||||
'created_at' => $brand->created_at->toISOString(),
|
||||
'created_at_formatted' => $brand->created_at->format('M d, Y'),
|
||||
'updated_at' => $brand->updated_at->toISOString(),
|
||||
'updated_at_formatted' => $brand->updated_at->format('M d, Y'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands', 'brandsData'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new brand
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
return view('seller.brands.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created brand in storage
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
\Illuminate\Validation\Rule::unique('brands', 'name')
|
||||
->where('business_id', $business->id)
|
||||
->whereNull('deleted_at'),
|
||||
],
|
||||
'description' => 'nullable|string',
|
||||
'tagline' => 'nullable|string|max:255',
|
||||
'sku_prefix' => 'nullable|string|max:10|alpha_num',
|
||||
'website_url' => 'nullable|url|max:255',
|
||||
'instagram_handle' => 'nullable|string|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'twitter_handle' => 'nullable|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'logo' => 'nullable|image|mimes:jpeg,png,jpg,webp,svg,gif|max:2048', // Max 2MB
|
||||
'banner' => 'nullable|image|mimes:jpeg,png,jpg,webp,gif|max:5120', // Max 5MB
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Add business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_public'] = $request->has('is_public');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? 0;
|
||||
|
||||
// Create brand first (need ID for logo/banner path)
|
||||
unset($validated['logo'], $validated['banner']); // Remove images from validated data temporarily
|
||||
$brand = Brand::create($validated);
|
||||
|
||||
// Handle logo upload after brand is created
|
||||
if ($request->hasFile('logo')) {
|
||||
// Store logo with UUID-based path
|
||||
$storagePath = "businesses/{$business->uuid}/brands/{$brand->id}";
|
||||
$fileName = uniqid().'.'.$request->file('logo')->getClientOriginalExtension();
|
||||
$logoPath = $request->file('logo')->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
// Update brand with logo path
|
||||
$brand->update(['logo_path' => $logoPath]);
|
||||
}
|
||||
|
||||
// Handle banner upload after brand is created
|
||||
if ($request->hasFile('banner')) {
|
||||
// Store banner with UUID-based path
|
||||
$storagePath = "businesses/{$business->uuid}/brands/{$brand->id}";
|
||||
$fileName = 'banner_'.uniqid().'.'.$request->file('banner')->getClientOriginalExtension();
|
||||
$bannerPath = $request->file('banner')->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
// Update brand with banner path
|
||||
$brand->update(['banner_path' => $bannerPath]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified brand
|
||||
*/
|
||||
public function edit(Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business
|
||||
if ($brand->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this brand.');
|
||||
}
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified brand in storage
|
||||
*/
|
||||
public function update(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business
|
||||
if ($brand->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this brand.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
\Illuminate\Validation\Rule::unique('brands', 'name')
|
||||
->where('business_id', $business->id)
|
||||
->whereNull('deleted_at')
|
||||
->ignore($brand->id),
|
||||
],
|
||||
'description' => 'nullable|string',
|
||||
'tagline' => 'nullable|string|max:255',
|
||||
'sku_prefix' => 'nullable|string|max:10|alpha_num',
|
||||
'website_url' => 'nullable|url|max:255',
|
||||
'instagram_handle' => 'nullable|string|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'twitter_handle' => 'nullable|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'logo' => 'nullable|image|mimes:jpeg,png,jpg,webp,svg,gif|max:2048', // Max 2MB
|
||||
'banner' => 'nullable|image|mimes:jpeg,png,jpg,webp,gif|max:5120', // Max 5MB
|
||||
'remove_logo' => 'nullable|boolean',
|
||||
'remove_banner' => 'nullable|boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$validated['is_public'] = $request->has('is_public');
|
||||
$validated['is_featured'] = $request->has('is_featured');
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? $brand->sort_order;
|
||||
|
||||
// Handle logo removal
|
||||
if ($request->has('remove_logo') && $request->remove_logo) {
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
$validated['logo_path'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
// Delete old logo if exists
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
|
||||
// Store new logo
|
||||
$storagePath = "businesses/{$business->uuid}/brands/{$brand->id}";
|
||||
$fileName = uniqid().'.'.$request->file('logo')->getClientOriginalExtension();
|
||||
$logoPath = $request->file('logo')->storeAs($storagePath, $fileName, 'public');
|
||||
$validated['logo_path'] = $logoPath;
|
||||
}
|
||||
|
||||
// Handle banner removal
|
||||
if ($request->has('remove_banner') && $request->remove_banner) {
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
$validated['banner_path'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new banner upload
|
||||
if ($request->hasFile('banner')) {
|
||||
// Delete old banner if exists
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
|
||||
// Store new banner
|
||||
$storagePath = "businesses/{$business->uuid}/brands/{$brand->id}";
|
||||
$fileName = 'banner_'.uniqid().'.'.$request->file('banner')->getClientOriginalExtension();
|
||||
$bannerPath = $request->file('banner')->storeAs($storagePath, $fileName, 'public');
|
||||
$validated['banner_path'] = $bannerPath;
|
||||
}
|
||||
|
||||
// Remove temporary fields
|
||||
unset($validated['logo'], $validated['banner']);
|
||||
unset($validated['remove_logo'], $validated['remove_banner']);
|
||||
|
||||
$brand->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified brand from storage
|
||||
*/
|
||||
public function destroy(Business $business, Brand $brand)
|
||||
{
|
||||
// Ensure brand belongs to this business
|
||||
if ($brand->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this brand.');
|
||||
}
|
||||
|
||||
// Check if brand has products
|
||||
if ($brand->products()->exists()) {
|
||||
return back()->with('error', 'Cannot delete a brand that has products. Please move or delete the products first.');
|
||||
}
|
||||
|
||||
// Delete logo if exists
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
|
||||
// Delete banner if exists
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
|
||||
$brand->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete brands
|
||||
*/
|
||||
public function bulkDelete(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'brand_ids' => 'required|array',
|
||||
'brand_ids.*' => 'exists:brands,id',
|
||||
]);
|
||||
|
||||
$brandIds = $request->input('brand_ids');
|
||||
|
||||
// Get brands that belong to this business
|
||||
$brands = Brand::whereIn('id', $brandIds)
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($brands->isEmpty()) {
|
||||
return back()->with('error', 'No valid brands selected for deletion.');
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
// Check if brand has products
|
||||
if ($brand->products()->exists()) {
|
||||
$errors[] = "'{$brand->name}' has products and cannot be deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete logo if exists
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
|
||||
// Delete banner if exists
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
|
||||
$brand->delete();
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$message = "Successfully deleted {$deleted} ".str()->plural('brand', $deleted).'.';
|
||||
|
||||
if (! empty($errors)) {
|
||||
$message .= ' However, some brands could not be deleted: '.implode(' ', $errors);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with($deleted > 0 ? 'success' : 'error', $message);
|
||||
}
|
||||
}
|
||||
434
app/Http/Controllers/Seller/ComponentCategoryController.php
Normal file
434
app/Http/Controllers/Seller/ComponentCategoryController.php
Normal file
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ComponentCategory;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ComponentCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of component categories for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all categories for this business with their relationships
|
||||
$categories = ComponentCategory::where('business_id', $business->id)
|
||||
->with(['parent', 'children'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Separate root categories and children for tree structure
|
||||
$rootCategories = $categories->filter(fn ($cat) => $cat->parent_id === null);
|
||||
|
||||
// Prepare categories data for JavaScript (client-side search/sort/pagination)
|
||||
$categoriesData = $categories->map(function ($category) {
|
||||
// Calculate hierarchy level
|
||||
$level = 0;
|
||||
if ($category->parent_id !== null && $category->parent) {
|
||||
$level = 1;
|
||||
if ($category->parent->parent_id !== null) {
|
||||
$level = 2;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'slug' => $category->slug,
|
||||
'name' => $category->name,
|
||||
'description' => $category->description,
|
||||
'parent_id' => $category->parent_id,
|
||||
'parent_name' => $category->parent ? $category->parent->name : null,
|
||||
'public' => $category->public,
|
||||
'sort_order' => $category->sort_order,
|
||||
'components_count' => $category->components->count(),
|
||||
'children_count' => $category->children->count(),
|
||||
'created_at' => $category->created_at->toISOString(),
|
||||
'created_at_formatted' => $category->created_at->format('M d, Y'),
|
||||
'level' => $level,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('seller.component-categories.index', compact('business', 'categories', 'rootCategories', 'categoriesData'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new component category
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Get all categories for this business to use as potential parents
|
||||
$categories = ComponentCategory::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.component-categories.create', compact('business', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created component category in storage
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|exists:component_categories,id',
|
||||
'public' => 'boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Add business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['public'] = $request->has('public');
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? 0;
|
||||
|
||||
// Validate parent belongs to same business
|
||||
if (! empty($validated['parent_id'])) {
|
||||
$parent = ComponentCategory::find($validated['parent_id']);
|
||||
if ($parent && $parent->business_id !== $business->id) {
|
||||
return back()->withErrors(['parent_id' => 'Invalid parent category.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Create category
|
||||
ComponentCategory::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.component-categories.index', $business->slug)
|
||||
->with('success', 'Component category created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified component category
|
||||
*/
|
||||
public function edit(Business $business, ComponentCategory $componentCategory)
|
||||
{
|
||||
// Ensure category belongs to this business
|
||||
if ($componentCategory->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this category.');
|
||||
}
|
||||
|
||||
// Get all categories except this one and its descendants (to prevent circular references)
|
||||
$categories = ComponentCategory::where('business_id', $business->id)
|
||||
->where('id', '!=', $componentCategory->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(function ($cat) use ($componentCategory) {
|
||||
// Filter out descendants
|
||||
$current = $cat;
|
||||
while ($current->parent_id) {
|
||||
if ($current->parent_id === $componentCategory->id) {
|
||||
return false;
|
||||
}
|
||||
$current = $current->parent;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return view('seller.component-categories.edit', compact('business', 'componentCategory', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified component category in storage
|
||||
*/
|
||||
public function update(Request $request, Business $business, ComponentCategory $componentCategory)
|
||||
{
|
||||
// Ensure category belongs to this business
|
||||
if ($componentCategory->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this category.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|exists:component_categories,id',
|
||||
'public' => 'boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$validated['public'] = $request->has('public');
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? $componentCategory->sort_order;
|
||||
|
||||
// Validate parent belongs to same business and isn't a descendant
|
||||
if (! empty($validated['parent_id'])) {
|
||||
$parent = ComponentCategory::find($validated['parent_id']);
|
||||
if ($parent && $parent->business_id !== $business->id) {
|
||||
return back()->withErrors(['parent_id' => 'Invalid parent category.'])->withInput();
|
||||
}
|
||||
|
||||
// Check for circular reference
|
||||
$current = $parent;
|
||||
while ($current) {
|
||||
if ($current->id === $componentCategory->id) {
|
||||
return back()->withErrors(['parent_id' => 'Cannot set a descendant as parent.'])->withInput();
|
||||
}
|
||||
$current = $current->parent;
|
||||
}
|
||||
}
|
||||
|
||||
$componentCategory->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.component-categories.index', $business->slug)
|
||||
->with('success', 'Component category updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified component category from storage
|
||||
*/
|
||||
public function destroy(Business $business, ComponentCategory $componentCategory)
|
||||
{
|
||||
// Ensure category belongs to this business
|
||||
if ($componentCategory->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this category.');
|
||||
}
|
||||
|
||||
// Check if category has children
|
||||
if ($componentCategory->hasChildren()) {
|
||||
return back()->with('error', 'Cannot delete a category that has subcategories. Please delete or move the subcategories first.');
|
||||
}
|
||||
|
||||
// Check if category has components
|
||||
if ($componentCategory->components()->exists()) {
|
||||
return back()->with('error', 'Cannot delete a category that has components. Please move or delete the components first.');
|
||||
}
|
||||
|
||||
$componentCategory->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.component-categories.index', $business->slug)
|
||||
->with('success', 'Component category deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete component categories
|
||||
*/
|
||||
public function bulkDelete(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'category_ids' => 'required|array',
|
||||
'category_ids.*' => 'exists:component_categories,id',
|
||||
]);
|
||||
|
||||
$categoryIds = $request->input('category_ids');
|
||||
|
||||
// Get categories that belong to this business
|
||||
$categories = ComponentCategory::whereIn('id', $categoryIds)
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($categories->isEmpty()) {
|
||||
return back()->with('error', 'No valid categories selected for deletion.');
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
// Check if category has children
|
||||
if ($category->hasChildren()) {
|
||||
$errors[] = "'{$category->name}' has subcategories and cannot be deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if category has components
|
||||
if ($category->components()->exists()) {
|
||||
$errors[] = "'{$category->name}' has components and cannot be deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$message = "Successfully deleted {$deleted} ".str()->plural('category', $deleted).'.';
|
||||
|
||||
if (! empty($errors)) {
|
||||
$message .= ' However, some categories could not be deleted: '.implode(' ', $errors);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.component-categories.index', $business->slug)
|
||||
->with($deleted > 0 ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the import form for component categories
|
||||
*/
|
||||
public function import(Business $business)
|
||||
{
|
||||
return view('seller.component-categories.import', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a sample CSV template for importing component categories
|
||||
*/
|
||||
public function downloadSample(Business $business)
|
||||
{
|
||||
$filename = 'component-categories-sample.csv';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$sampleData = [
|
||||
['name', 'description', 'parent_name', 'public', 'sort_order'],
|
||||
['Raw Materials', 'Base ingredients and raw materials', '', 'true', '1'],
|
||||
['Cannabis Flower', 'Raw cannabis flower', 'Raw Materials', 'true', '1'],
|
||||
['Distillate', 'Cannabis distillate', 'Raw Materials', 'true', '2'],
|
||||
['Terpenes', 'Terpene profiles', 'Raw Materials', 'true', '3'],
|
||||
['Packaging', 'Product packaging materials', '', 'true', '2'],
|
||||
['Bottles', 'Glass and plastic bottles', 'Packaging', 'true', '1'],
|
||||
['Labels', 'Product labels and stickers', 'Packaging', 'true', '2'],
|
||||
['Hardware', 'Vape hardware and cartridges', '', 'true', '3'],
|
||||
['Ingredients', 'Food-grade ingredients', '', 'true', '4'],
|
||||
];
|
||||
|
||||
$callback = function () use ($sampleData) {
|
||||
$file = fopen('php://output', 'w');
|
||||
foreach ($sampleData as $row) {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the CSV import for component categories
|
||||
*/
|
||||
public function processImport(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt|max:2048', // Max 2MB
|
||||
]);
|
||||
|
||||
try {
|
||||
$file = $request->file('file');
|
||||
$handle = fopen($file->getPathname(), 'r');
|
||||
|
||||
// Read header row
|
||||
$header = fgetcsv($handle);
|
||||
|
||||
// Validate header
|
||||
$expectedHeaders = ['name', 'description', 'parent_name', 'public', 'sort_order'];
|
||||
$headerLower = array_map('strtolower', array_map('trim', $header));
|
||||
|
||||
$missingHeaders = array_diff($expectedHeaders, $headerLower);
|
||||
if (! empty($missingHeaders)) {
|
||||
fclose($handle);
|
||||
|
||||
return back()->with('error', 'Invalid CSV format. Missing columns: '.implode(', ', $missingHeaders));
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
$errors = [];
|
||||
$row = 1; // Start at 1 for header
|
||||
|
||||
// First pass: import categories without parents
|
||||
$pendingCategories = [];
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$row++;
|
||||
|
||||
if (empty($data[0])) { // Skip rows without name
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryData = [
|
||||
'name' => trim($data[0]),
|
||||
'description' => isset($data[1]) ? trim($data[1]) : null,
|
||||
'parent_name' => isset($data[2]) ? trim($data[2]) : null,
|
||||
'public' => isset($data[3]) ? filter_var(trim($data[3]), FILTER_VALIDATE_BOOLEAN) : true,
|
||||
'sort_order' => isset($data[4]) && is_numeric($data[4]) ? (int) $data[4] : 0,
|
||||
];
|
||||
|
||||
$pendingCategories[] = [
|
||||
'data' => $categoryData,
|
||||
'row' => $row,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// Create categories in two passes to handle parent relationships
|
||||
// First pass: create all root categories
|
||||
foreach ($pendingCategories as $pending) {
|
||||
$categoryData = $pending['data'];
|
||||
$rowNum = $pending['row'];
|
||||
|
||||
if (empty($categoryData['parent_name'])) {
|
||||
try {
|
||||
ComponentCategory::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $categoryData['name'],
|
||||
'description' => $categoryData['description'],
|
||||
'parent_id' => null,
|
||||
'public' => $categoryData['public'],
|
||||
'sort_order' => $categoryData['sort_order'],
|
||||
]);
|
||||
$imported++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Row {$rowNum}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create categories with parents
|
||||
foreach ($pendingCategories as $pending) {
|
||||
$categoryData = $pending['data'];
|
||||
$rowNum = $pending['row'];
|
||||
|
||||
if (! empty($categoryData['parent_name'])) {
|
||||
try {
|
||||
// Find parent category
|
||||
$parent = ComponentCategory::where('business_id', $business->id)
|
||||
->where('name', $categoryData['parent_name'])
|
||||
->first();
|
||||
|
||||
if (! $parent) {
|
||||
$errors[] = "Row {$rowNum}: Parent category '{$categoryData['parent_name']}' not found";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ComponentCategory::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $categoryData['name'],
|
||||
'description' => $categoryData['description'],
|
||||
'parent_id' => $parent->id,
|
||||
'public' => $categoryData['public'],
|
||||
'sort_order' => $categoryData['sort_order'],
|
||||
]);
|
||||
$imported++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Row {$rowNum}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$message = "Successfully imported {$imported} categories.";
|
||||
if (! empty($errors)) {
|
||||
$message .= ' Errors: '.implode('; ', $errors);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.component-categories.index', $business->slug)
|
||||
->with($imported > 0 ? 'success' : 'error', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Error processing file: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Component;
|
||||
use App\Models\ComponentCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -56,7 +57,13 @@ class ComponentController extends Controller
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
return view('seller.components.create', compact('business'));
|
||||
// Get all categories for this business
|
||||
$categories = ComponentCategory::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.components.create', compact('business', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +75,8 @@ class ComponentController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'sku' => 'required|string|max:100|unique:components,sku',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|string|in:flower,concentrate,packaging,hardware,paper,flavoring,carrier_oil,other',
|
||||
'category_id' => 'nullable|exists:component_categories,id',
|
||||
'type' => 'nullable|string|in:flower,concentrate,packaging,hardware,paper,flavoring,carrier_oil,other',
|
||||
'cost_per_unit' => 'required|numeric|min:0',
|
||||
'unit_of_measure' => 'required|string|in:unit,g,oz,lb,kg,ml,l',
|
||||
'quantity_on_hand' => 'nullable|integer|min:0',
|
||||
@@ -82,6 +90,14 @@ class ComponentController extends Controller
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120', // 5MB max
|
||||
]);
|
||||
|
||||
// Validate category belongs to same business if provided
|
||||
if (! empty($validated['category_id'])) {
|
||||
$category = ComponentCategory::find($validated['category_id']);
|
||||
if ($category && $category->business_id !== $business->id) {
|
||||
return back()->withErrors(['category_id' => 'Invalid category.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Add business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
|
||||
@@ -117,7 +133,13 @@ class ComponentController extends Controller
|
||||
abort(403, 'This component does not belong to your business');
|
||||
}
|
||||
|
||||
return view('seller.components.edit', compact('business', 'component'));
|
||||
// Get all categories for this business
|
||||
$categories = ComponentCategory::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.components.edit', compact('business', 'component', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +156,8 @@ class ComponentController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'sku' => 'required|string|max:100|unique:components,sku,'.$component->id,
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|string|in:flower,concentrate,packaging,hardware,paper,flavoring,carrier_oil,other',
|
||||
'category_id' => 'nullable|exists:component_categories,id',
|
||||
'type' => 'nullable|string|in:flower,concentrate,packaging,hardware,paper,flavoring,carrier_oil,other',
|
||||
'cost_per_unit' => 'required|numeric|min:0',
|
||||
'unit_of_measure' => 'required|string|in:unit,g,oz,lb,kg,ml,l',
|
||||
'quantity_on_hand' => 'nullable|integer|min:0',
|
||||
@@ -149,6 +172,14 @@ class ComponentController extends Controller
|
||||
'remove_image' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Validate category belongs to same business if provided
|
||||
if (! empty($validated['category_id'])) {
|
||||
$category = ComponentCategory::find($validated['category_id']);
|
||||
if ($category && $category->business_id !== $business->id) {
|
||||
return back()->withErrors(['category_id' => 'Invalid category.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $component->name) {
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
|
||||
501
app/Http/Controllers/Seller/ProductCategoryController.php
Normal file
501
app/Http/Controllers/Seller/ProductCategoryController.php
Normal file
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of product categories for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all categories for this business with their relationships
|
||||
$categories = ProductCategory::where('business_id', $business->id)
|
||||
->with(['parent', 'children'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Separate root categories and children for tree structure
|
||||
$rootCategories = $categories->filter(fn ($cat) => $cat->parent_id === null);
|
||||
|
||||
// Prepare categories data for JavaScript (client-side search/sort/pagination)
|
||||
$categoriesData = $categories->map(function ($category) {
|
||||
// Calculate hierarchy level
|
||||
$level = 0;
|
||||
if ($category->parent_id !== null && $category->parent) {
|
||||
$level = 1;
|
||||
if ($category->parent->parent_id !== null) {
|
||||
$level = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Get image URL with fallback to parent/root category
|
||||
$imageUrl = null;
|
||||
if ($category->image_path) {
|
||||
// Category has its own image
|
||||
$imageUrl = asset('storage/'.$category->image_path);
|
||||
} elseif ($category->parent && $category->parent->image_path) {
|
||||
// Use parent's image
|
||||
$imageUrl = asset('storage/'.$category->parent->image_path);
|
||||
} elseif ($category->parent && $category->parent->parent && $category->parent->parent->image_path) {
|
||||
// For level 2 categories, fallback to grandparent (root) image
|
||||
$imageUrl = asset('storage/'.$category->parent->parent->image_path);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'slug' => $category->slug,
|
||||
'name' => $category->name,
|
||||
'description' => $category->description,
|
||||
'parent_id' => $category->parent_id,
|
||||
'parent_name' => $category->parent ? $category->parent->name : null,
|
||||
'public' => $category->public,
|
||||
'sort_order' => $category->sort_order,
|
||||
'products_count' => $category->products->count(),
|
||||
'children_count' => $category->children->count(),
|
||||
'image_path' => $category->image_path,
|
||||
'image_url' => $imageUrl,
|
||||
'created_at' => $category->created_at->toISOString(),
|
||||
'created_at_formatted' => $category->created_at->format('M d, Y'),
|
||||
'level' => $level,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('seller.product-categories.index', compact('business', 'categories', 'rootCategories', 'categoriesData'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new product category
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Get all categories for this business to use as potential parents
|
||||
$categories = ProductCategory::where('business_id', $business->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.product-categories.create', compact('business', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created product category in storage
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'public' => 'boolean',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp,gif|max:2048', // Max 2MB
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Add business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['public'] = $request->has('public');
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? 0;
|
||||
|
||||
// Validate parent belongs to same business
|
||||
if (! empty($validated['parent_id'])) {
|
||||
$parent = ProductCategory::find($validated['parent_id']);
|
||||
if ($parent && $parent->business_id !== $business->id) {
|
||||
return back()->withErrors(['parent_id' => 'Invalid parent category.'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Create category first (need ID for image path)
|
||||
unset($validated['image']); // Remove image from validated data temporarily
|
||||
$category = ProductCategory::create($validated);
|
||||
|
||||
// Handle image upload after category is created
|
||||
if ($request->hasFile('image')) {
|
||||
// Store image with UUID-based path
|
||||
$storagePath = "businesses/{$business->uuid}/product-categories/{$category->id}";
|
||||
$fileName = uniqid().'.'.$request->file('image')->getClientOriginalExtension();
|
||||
$imagePath = $request->file('image')->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
// Update category with image path
|
||||
$category->update(['image_path' => $imagePath]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.product-categories.index', $business->slug)
|
||||
->with('success', 'Product category created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified product category
|
||||
*/
|
||||
public function edit(Business $business, ProductCategory $productCategory)
|
||||
{
|
||||
// Ensure category belongs to this business
|
||||
if ($productCategory->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this category.');
|
||||
}
|
||||
|
||||
// Get all categories except this one and its descendants (to prevent circular references)
|
||||
$categories = ProductCategory::where('business_id', $business->id)
|
||||
->where('id', '!=', $productCategory->id)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(function ($cat) use ($productCategory) {
|
||||
// Filter out descendants
|
||||
$current = $cat;
|
||||
while ($current->parent_id) {
|
||||
if ($current->parent_id === $productCategory->id) {
|
||||
return false;
|
||||
}
|
||||
$current = $current->parent;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return view('seller.product-categories.edit', compact('business', 'productCategory', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified product category in storage
|
||||
*/
|
||||
public function update(Request $request, Business $business, ProductCategory $productCategory)
|
||||
{
|
||||
// Ensure category belongs to this business
|
||||
if ($productCategory->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this category.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'public' => 'boolean',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp,gif|max:2048', // Max 2MB
|
||||
'remove_image' => 'nullable|boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$validated['public'] = $request->has('public');
|
||||
$validated['sort_order'] = $validated['sort_order'] ?? $productCategory->sort_order;
|
||||
|
||||
// Validate parent belongs to same business and isn't a descendant
|
||||
if (! empty($validated['parent_id'])) {
|
||||
$parent = ProductCategory::find($validated['parent_id']);
|
||||
if ($parent && $parent->business_id !== $business->id) {
|
||||
return back()->withErrors(['parent_id' => 'Invalid parent category.'])->withInput();
|
||||
}
|
||||
|
||||
// Check for circular reference
|
||||
$current = $parent;
|
||||
while ($current) {
|
||||
if ($current->id === $productCategory->id) {
|
||||
return back()->withErrors(['parent_id' => 'Cannot set a descendant as parent.'])->withInput();
|
||||
}
|
||||
$current = $current->parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image removal
|
||||
if ($request->has('remove_image') && $request->remove_image) {
|
||||
if ($productCategory->image_path) {
|
||||
Storage::disk('public')->delete($productCategory->image_path);
|
||||
$validated['image_path'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new image upload
|
||||
if ($request->hasFile('image')) {
|
||||
// Delete old image if exists
|
||||
if ($productCategory->image_path) {
|
||||
Storage::disk('public')->delete($productCategory->image_path);
|
||||
}
|
||||
|
||||
// Store new image
|
||||
$storagePath = "businesses/{$business->uuid}/product-categories/{$productCategory->id}";
|
||||
$fileName = uniqid().'.'.$request->file('image')->getClientOriginalExtension();
|
||||
$imagePath = $request->file('image')->storeAs($storagePath, $fileName, 'public');
|
||||
$validated['image_path'] = $imagePath;
|
||||
}
|
||||
|
||||
// Remove temporary fields
|
||||
unset($validated['image']);
|
||||
unset($validated['remove_image']);
|
||||
|
||||
$productCategory->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.product-categories.index', $business->slug)
|
||||
->with('success', 'Product category updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified product category from storage
|
||||
*/
|
||||
public function destroy(Business $business, ProductCategory $productCategory)
|
||||
{
|
||||
// Ensure category belongs to this business
|
||||
if ($productCategory->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this category.');
|
||||
}
|
||||
|
||||
// Check if category has children
|
||||
if ($productCategory->hasChildren()) {
|
||||
return back()->with('error', 'Cannot delete a category that has subcategories. Please delete or move the subcategories first.');
|
||||
}
|
||||
|
||||
// Check if category has products
|
||||
if ($productCategory->products()->exists()) {
|
||||
return back()->with('error', 'Cannot delete a category that has products. Please move or delete the products first.');
|
||||
}
|
||||
|
||||
// Delete image if exists
|
||||
if ($productCategory->image_path) {
|
||||
Storage::disk('public')->delete($productCategory->image_path);
|
||||
}
|
||||
|
||||
$productCategory->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.product-categories.index', $business->slug)
|
||||
->with('success', 'Product category deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete product categories
|
||||
*/
|
||||
public function bulkDelete(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'category_ids' => 'required|array',
|
||||
'category_ids.*' => 'exists:product_categories,id',
|
||||
]);
|
||||
|
||||
$categoryIds = $request->input('category_ids');
|
||||
|
||||
// Get categories that belong to this business
|
||||
$categories = ProductCategory::whereIn('id', $categoryIds)
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($categories->isEmpty()) {
|
||||
return back()->with('error', 'No valid categories selected for deletion.');
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
// Check if category has children
|
||||
if ($category->hasChildren()) {
|
||||
$errors[] = "'{$category->name}' has subcategories and cannot be deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if category has products
|
||||
if ($category->products()->exists()) {
|
||||
$errors[] = "'{$category->name}' has products and cannot be deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete image if exists
|
||||
if ($category->image_path) {
|
||||
Storage::disk('public')->delete($category->image_path);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$message = "Successfully deleted {$deleted} ".str()->plural('category', $deleted).'.';
|
||||
|
||||
if (! empty($errors)) {
|
||||
$message .= ' However, some categories could not be deleted: '.implode(' ', $errors);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.product-categories.index', $business->slug)
|
||||
->with($deleted > 0 ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the import form for product categories
|
||||
*/
|
||||
public function import(Business $business)
|
||||
{
|
||||
return view('seller.product-categories.import', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a sample CSV template for importing product categories
|
||||
*/
|
||||
public function downloadSample(Business $business)
|
||||
{
|
||||
$filename = 'product-categories-sample.csv';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$sampleData = [
|
||||
['name', 'description', 'parent_name', 'public', 'sort_order'],
|
||||
['Flower', 'Cannabis flower products', '', 'true', '1'],
|
||||
['Indica', 'Relaxing strains', 'Flower', 'true', '1'],
|
||||
['Sativa', 'Energizing strains', 'Flower', 'true', '2'],
|
||||
['Hybrid', 'Balanced strains', 'Flower', 'true', '3'],
|
||||
['Edibles', 'Infused food products', '', 'true', '2'],
|
||||
['Gummies', 'Chewy candies', 'Edibles', 'true', '1'],
|
||||
['Chocolates', 'Infused chocolate', 'Edibles', 'true', '2'],
|
||||
['Concentrates', 'Extracted cannabis products', '', 'true', '3'],
|
||||
['Vape Cartridges', 'Pre-filled vape carts', '', 'true', '4'],
|
||||
];
|
||||
|
||||
$callback = function () use ($sampleData) {
|
||||
$file = fopen('php://output', 'w');
|
||||
foreach ($sampleData as $row) {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the CSV import for product categories
|
||||
*/
|
||||
public function processImport(Request $request, Business $business)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt|max:2048', // Max 2MB
|
||||
]);
|
||||
|
||||
try {
|
||||
$file = $request->file('file');
|
||||
$handle = fopen($file->getPathname(), 'r');
|
||||
|
||||
// Read header row
|
||||
$header = fgetcsv($handle);
|
||||
|
||||
// Validate header
|
||||
$expectedHeaders = ['name', 'description', 'parent_name', 'public', 'sort_order'];
|
||||
$headerLower = array_map('strtolower', array_map('trim', $header));
|
||||
|
||||
$missingHeaders = array_diff($expectedHeaders, $headerLower);
|
||||
if (! empty($missingHeaders)) {
|
||||
fclose($handle);
|
||||
|
||||
return back()->with('error', 'Invalid CSV format. Missing columns: '.implode(', ', $missingHeaders));
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
$errors = [];
|
||||
$row = 1; // Start at 1 for header
|
||||
|
||||
// First pass: import categories without parents
|
||||
$pendingCategories = [];
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$row++;
|
||||
|
||||
if (empty($data[0])) { // Skip rows without name
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryData = [
|
||||
'name' => trim($data[0]),
|
||||
'description' => isset($data[1]) ? trim($data[1]) : null,
|
||||
'parent_name' => isset($data[2]) ? trim($data[2]) : null,
|
||||
'public' => isset($data[3]) ? filter_var(trim($data[3]), FILTER_VALIDATE_BOOLEAN) : true,
|
||||
'sort_order' => isset($data[4]) && is_numeric($data[4]) ? (int) $data[4] : 0,
|
||||
];
|
||||
|
||||
$pendingCategories[] = [
|
||||
'data' => $categoryData,
|
||||
'row' => $row,
|
||||
];
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// Create categories in two passes to handle parent relationships
|
||||
// First pass: create all root categories
|
||||
foreach ($pendingCategories as $pending) {
|
||||
$categoryData = $pending['data'];
|
||||
$rowNum = $pending['row'];
|
||||
|
||||
if (empty($categoryData['parent_name'])) {
|
||||
try {
|
||||
ProductCategory::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $categoryData['name'],
|
||||
'description' => $categoryData['description'],
|
||||
'parent_id' => null,
|
||||
'public' => $categoryData['public'],
|
||||
'sort_order' => $categoryData['sort_order'],
|
||||
]);
|
||||
$imported++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Row {$rowNum}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create categories with parents
|
||||
foreach ($pendingCategories as $pending) {
|
||||
$categoryData = $pending['data'];
|
||||
$rowNum = $pending['row'];
|
||||
|
||||
if (! empty($categoryData['parent_name'])) {
|
||||
try {
|
||||
// Find parent category
|
||||
$parent = ProductCategory::where('business_id', $business->id)
|
||||
->where('name', $categoryData['parent_name'])
|
||||
->first();
|
||||
|
||||
if (! $parent) {
|
||||
$errors[] = "Row {$rowNum}: Parent category '{$categoryData['parent_name']}' not found";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ProductCategory::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => $categoryData['name'],
|
||||
'description' => $categoryData['description'],
|
||||
'parent_id' => $parent->id,
|
||||
'public' => $categoryData['public'],
|
||||
'sort_order' => $categoryData['sort_order'],
|
||||
]);
|
||||
$imported++;
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Row {$rowNum}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$message = "Successfully imported {$imported} categories.";
|
||||
if (! empty($errors)) {
|
||||
$message .= ' Errors: '.implode('; ', $errors);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.product-categories.index', $business->slug)
|
||||
->with($imported > 0 ? 'success' : 'error', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Error processing file: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,10 +83,20 @@ class ProductController extends Controller
|
||||
return back()->with('error', 'Please create at least one brand before adding products.');
|
||||
}
|
||||
|
||||
// Get product categories for this business
|
||||
$categories = $business->productCategories()
|
||||
->where('public', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get units for this business
|
||||
$units = $business->units()->orderBy('unit')->get();
|
||||
|
||||
// Pre-select brand if one is selected in context switcher
|
||||
$selectedBrand = BrandSwitcherController::getSelectedBrand();
|
||||
|
||||
return view('seller.products.create', compact('business', 'brands', 'selectedBrand'));
|
||||
return view('seller.products.create', compact('business', 'brands', 'categories', 'units', 'selectedBrand'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,11 +110,11 @@ class ProductController extends Controller
|
||||
'sku' => 'required|string|max:100|unique:products,sku',
|
||||
'description' => 'nullable|string',
|
||||
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'wholesale_price' => 'required|numeric|min:0',
|
||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'net_weight' => 'nullable|numeric|min:0',
|
||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
||||
'unit_id' => 'nullable|exists:units,id',
|
||||
'units_per_case' => 'nullable|integer|min:1',
|
||||
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
@@ -150,7 +160,7 @@ class ProductController extends Controller
|
||||
public function edit(Business $business, Product $product)
|
||||
{
|
||||
// Eager load relationships
|
||||
$product->load(['brand', 'images']);
|
||||
$product->load(['brand', 'images', 'category']);
|
||||
|
||||
// Verify product belongs to a brand under this business
|
||||
if (! $product->brand || $product->brand->business_id !== $business->id) {
|
||||
@@ -159,13 +169,23 @@ class ProductController extends Controller
|
||||
|
||||
$brands = $business->brands()->orderBy('name')->get();
|
||||
|
||||
// Get product categories for this business
|
||||
$categories = $business->productCategories()
|
||||
->where('public', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get units for this business
|
||||
$units = $business->units()->orderBy('unit')->get();
|
||||
|
||||
// Load audits with pagination (10 per page) for the audit history tab
|
||||
$audits = $product->audits()
|
||||
->with('user')
|
||||
->latest()
|
||||
->paginate(10);
|
||||
|
||||
return view('seller.products.edit', compact('business', 'product', 'brands', 'audits'));
|
||||
return view('seller.products.edit', compact('business', 'product', 'brands', 'categories', 'units', 'audits'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,11 +207,11 @@ class ProductController extends Controller
|
||||
'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',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'wholesale_price' => 'required|numeric|min:0',
|
||||
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
|
||||
'net_weight' => 'nullable|numeric|min:0',
|
||||
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
|
||||
'unit_id' => 'nullable|exists:units,id',
|
||||
'units_per_case' => 'nullable|integer|min:1',
|
||||
'thc_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
|
||||
|
||||
135
app/Http/Controllers/Seller/UnitController.php
Normal file
135
app/Http/Controllers/Seller/UnitController.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UnitController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of units for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get all units for this business
|
||||
$units = Unit::where('business_id', $business->id)
|
||||
->orderBy('unit')
|
||||
->get();
|
||||
|
||||
// Prepare units data for JavaScript
|
||||
$unitsData = $units->map(function ($unit) {
|
||||
return [
|
||||
'id' => $unit->id,
|
||||
'unit' => $unit->unit,
|
||||
'created_at' => $unit->created_at->toISOString(),
|
||||
'created_at_formatted' => $unit->created_at->format('M d, Y'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('seller.units.index', compact('business', 'units', 'unitsData'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new unit
|
||||
* Note: This method is kept for backward compatibility but the main UI uses modals
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
// Redirect to index since we use modals now
|
||||
return redirect()->route('seller.business.units.index', $business->slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created unit in storage
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'unit' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
// Add business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
|
||||
// Create unit
|
||||
$unit = Unit::create($validated);
|
||||
|
||||
// Return JSON response for AJAX requests
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Unit created successfully.',
|
||||
'unit' => $unit,
|
||||
], 201);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.units.index', $business->slug)
|
||||
->with('success', 'Unit created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified unit
|
||||
* Note: This method is kept for backward compatibility but the main UI uses modals
|
||||
*/
|
||||
public function edit(Business $business, Unit $unit)
|
||||
{
|
||||
// Ensure unit belongs to this business
|
||||
if ($unit->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this unit.');
|
||||
}
|
||||
|
||||
// Redirect to index since we use modals now
|
||||
return redirect()->route('seller.business.units.index', $business->slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified unit in storage
|
||||
*/
|
||||
public function update(Request $request, Business $business, Unit $unit)
|
||||
{
|
||||
// Ensure unit belongs to this business
|
||||
if ($unit->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this unit.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'unit' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$unit->update($validated);
|
||||
|
||||
// Return JSON response for AJAX requests
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Unit updated successfully.',
|
||||
'unit' => $unit,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.units.index', $business->slug)
|
||||
->with('success', 'Unit updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified unit from storage
|
||||
*/
|
||||
public function destroy(Business $business, Unit $unit)
|
||||
{
|
||||
// Ensure unit belongs to this business
|
||||
if ($unit->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to this unit.');
|
||||
}
|
||||
|
||||
$unit->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.units.index', $business->slug)
|
||||
->with('success', 'Unit deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class Brand extends Model
|
||||
|
||||
// Branding Assets
|
||||
'logo_path',
|
||||
'banner_path',
|
||||
'website_url',
|
||||
'colors', // JSON: hex color codes for theming
|
||||
|
||||
@@ -175,11 +176,21 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
* Generate slug from name, ensuring uniqueness even with soft-deleted brands
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
{
|
||||
return Str::slug($this->name);
|
||||
$slug = Str::slug($this->name);
|
||||
$originalSlug = $slug;
|
||||
$counter = 1;
|
||||
|
||||
// Check if slug exists (including soft-deleted)
|
||||
while (static::withTrashed()->where('slug', $slug)->where('id', '!=', $this->id ?? 0)->exists()) {
|
||||
$slug = $originalSlug.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +231,44 @@ class Brand extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if brand has a banner
|
||||
*/
|
||||
public function hasBanner(): bool
|
||||
{
|
||||
return ! empty($this->banner_path) && \Storage::disk('public')->exists($this->banner_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for the brand banner
|
||||
*/
|
||||
public function getBannerUrl(): ?string
|
||||
{
|
||||
if (! $this->banner_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset('storage/'.$this->banner_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete banner file from storage
|
||||
*/
|
||||
public function deleteBannerFile(): bool
|
||||
{
|
||||
if (! $this->banner_path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$disk = \Storage::disk('public');
|
||||
|
||||
if ($disk->exists($this->banner_path)) {
|
||||
return $disk->delete($this->banner_path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot method to auto-generate slug
|
||||
*/
|
||||
@@ -232,5 +281,12 @@ class Brand extends Model
|
||||
$brand->slug = $brand->generateSlug();
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($brand) {
|
||||
// Regenerate slug if name changed
|
||||
if ($brand->isDirty('name')) {
|
||||
$brand->slug = $brand->generateSlug();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,11 @@ class Business extends Model implements AuditableContract
|
||||
return $this->hasMany(Component::class);
|
||||
}
|
||||
|
||||
public function productCategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCategory::class);
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
@@ -244,6 +249,11 @@ class Business extends Model implements AuditableContract
|
||||
return $this->hasMany(Brand::class);
|
||||
}
|
||||
|
||||
public function units(): HasMany
|
||||
{
|
||||
return $this->hasMany(Unit::class);
|
||||
}
|
||||
|
||||
// Note: Corporate structure (parent/subsidiaries) not yet implemented
|
||||
// parent_business_id column doesn't exist in database
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class Component extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
@@ -69,6 +70,14 @@ class Component extends Model
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component belongs to a Category
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ComponentCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Products that use this component in their BOM
|
||||
*/
|
||||
|
||||
117
app/Models/ComponentCategory.php
Normal file
117
app/Models/ComponentCategory.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ComponentCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'parent_id',
|
||||
'name',
|
||||
'description',
|
||||
'public',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'public' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns this category.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent category.
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ComponentCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the child categories.
|
||||
*/
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ComponentCategory::class, 'parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all components in this category.
|
||||
*/
|
||||
public function components(): HasMany
|
||||
{
|
||||
return $this->hasMany(Component::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get only public categories.
|
||||
*/
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('public', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get only root categories (no parent).
|
||||
*/
|
||||
public function scopeRoots($query)
|
||||
{
|
||||
return $query->whereNull('parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a root category.
|
||||
*/
|
||||
public function isRoot(): bool
|
||||
{
|
||||
return $this->parent_id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this category has children.
|
||||
*/
|
||||
public function hasChildren(): bool
|
||||
{
|
||||
return $this->children()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ancestor categories.
|
||||
*/
|
||||
public function ancestors(): array
|
||||
{
|
||||
$ancestors = [];
|
||||
$category = $this;
|
||||
|
||||
while ($category->parent) {
|
||||
$ancestors[] = $category->parent;
|
||||
$category = $category->parent;
|
||||
}
|
||||
|
||||
return array_reverse($ancestors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path of this category (including ancestors).
|
||||
*/
|
||||
public function getPathAttribute(): string
|
||||
{
|
||||
$path = collect($this->ancestors())->pluck('name')->push($this->name);
|
||||
|
||||
return $path->implode(' > ');
|
||||
}
|
||||
}
|
||||
33
app/Models/Favorite.php
Normal file
33
app/Models/Favorite.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Favorite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'product_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns the favorite
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the product that is favorited
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class Product extends Model implements Auditable
|
||||
|
||||
protected $fillable = [
|
||||
'brand_id',
|
||||
'category_id',
|
||||
'strain_id',
|
||||
'parent_product_id',
|
||||
'name',
|
||||
@@ -33,6 +34,7 @@ class Product extends Model implements Auditable
|
||||
'price_unit',
|
||||
'net_weight',
|
||||
'weight_unit',
|
||||
'unit_id',
|
||||
'units_per_case',
|
||||
'thc_percentage',
|
||||
'cbd_percentage',
|
||||
@@ -111,6 +113,16 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Strain::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function unit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class, 'unit_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'parent_product_id');
|
||||
@@ -183,6 +195,16 @@ class Product extends Model implements Auditable
|
||||
->where('quantity_available', '>', 0);
|
||||
}
|
||||
|
||||
public function favorites()
|
||||
{
|
||||
return $this->hasMany(Favorite::class);
|
||||
}
|
||||
|
||||
public function favoritedBy()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'favorites')->withTimestamps();
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeActive($query)
|
||||
|
||||
192
app/Models/ProductCategory.php
Normal file
192
app/Models/ProductCategory.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'parent_id',
|
||||
'name',
|
||||
'description',
|
||||
'public',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'public' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns this category.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent category.
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the child categories.
|
||||
*/
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCategory::class, 'parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all products in this category.
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get only public categories.
|
||||
*/
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('public', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get only root categories (no parent).
|
||||
*/
|
||||
public function scopeRoots($query)
|
||||
{
|
||||
return $query->whereNull('parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a root category.
|
||||
*/
|
||||
public function isRoot(): bool
|
||||
{
|
||||
return $this->parent_id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this category has children.
|
||||
*/
|
||||
public function hasChildren(): bool
|
||||
{
|
||||
return $this->children()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ancestor categories.
|
||||
*/
|
||||
public function ancestors(): array
|
||||
{
|
||||
$ancestors = [];
|
||||
$category = $this;
|
||||
|
||||
while ($category->parent) {
|
||||
$ancestors[] = $category->parent;
|
||||
$category = $category->parent;
|
||||
}
|
||||
|
||||
return array_reverse($ancestors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path of this category (including ancestors).
|
||||
*/
|
||||
public function getPathAttribute(): string
|
||||
{
|
||||
$path = collect($this->ancestors())->pluck('name')->push($this->name);
|
||||
|
||||
return $path->implode(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the image path for this category or its parent (recursive).
|
||||
*/
|
||||
public function getEffectiveImagePath(): ?string
|
||||
{
|
||||
// If this category has an image, use it
|
||||
if (! empty($this->image_path)) {
|
||||
return $this->image_path;
|
||||
}
|
||||
|
||||
// If no parent, return null
|
||||
if (! $this->parent_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, check parent categories recursively using DB query to avoid N+1
|
||||
try {
|
||||
$parentId = $this->parent_id;
|
||||
$maxDepth = 10; // Prevent infinite loops
|
||||
$depth = 0;
|
||||
|
||||
while ($parentId && $depth < $maxDepth) {
|
||||
$depth++;
|
||||
$parent = self::find($parentId);
|
||||
|
||||
if (! $parent) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (! empty($parent->image_path)) {
|
||||
return $parent->image_path;
|
||||
}
|
||||
|
||||
$parentId = $parent->parent_id;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If anything fails, just return null
|
||||
\Log::warning('Failed to get parent category image: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the image URL for this category (falls back to parent if no image).
|
||||
*/
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
$imagePath = $this->getEffectiveImagePath();
|
||||
|
||||
if (! $imagePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use asset helper for Docker/WSL compatibility
|
||||
return asset('storage/'.$imagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category has an image (own or inherited from parent).
|
||||
*/
|
||||
public function hasImage(): bool
|
||||
{
|
||||
return ! empty($this->getEffectiveImagePath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category has its own image (not inherited).
|
||||
*/
|
||||
public function hasOwnImage(): bool
|
||||
{
|
||||
return ! empty($this->image_path);
|
||||
}
|
||||
}
|
||||
25
app/Models/Unit.php
Normal file
25
app/Models/Unit.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Unit extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'unit',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns this unit.
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,16 @@ class User extends Authenticatable implements FilamentUser
|
||||
return $this->hasMany(Contact::class);
|
||||
}
|
||||
|
||||
public function favorites()
|
||||
{
|
||||
return $this->hasMany(Favorite::class);
|
||||
}
|
||||
|
||||
public function favoriteProducts()
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'favorites')->withTimestamps();
|
||||
}
|
||||
|
||||
// Helper methods for business associations
|
||||
public function isPrimaryContactFor(Business $business): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained('businesses')->cascadeOnDelete();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('product_categories')->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('public')->default(true);
|
||||
$table->string('image_path')->nullable(); // Store image file path
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('business_id');
|
||||
$table->index('parent_id');
|
||||
$table->index('public');
|
||||
$table->index('sort_order');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_categories');
|
||||
}
|
||||
};
|
||||
@@ -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::table('products', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')
|
||||
->nullable()
|
||||
->after('brand_id')
|
||||
->constrained('product_categories')
|
||||
->onDelete('set null');
|
||||
|
||||
$table->index('category_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->dropIndex(['category_id']);
|
||||
$table->dropColumn('category_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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('component_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained('businesses')->cascadeOnDelete();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('component_categories')->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('public')->default(true);
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('business_id');
|
||||
$table->index('parent_id');
|
||||
$table->index('public');
|
||||
$table->index('sort_order');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('component_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('components', function (Blueprint $table) {
|
||||
$table->foreignId('category_id')->nullable()->after('business_id')->constrained('component_categories')->nullOnDelete();
|
||||
$table->index('category_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('components', function (Blueprint $table) {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->dropIndex(['category_id']);
|
||||
$table->dropColumn('category_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
31
database/migrations/2025_10_29_192402_create_units_table.php
Normal file
31
database/migrations/2025_10_29_192402_create_units_table.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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->foreignId('business_id')->constrained('businesses')->cascadeOnDelete();
|
||||
$table->string('unit');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('business_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('units');
|
||||
}
|
||||
};
|
||||
@@ -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::table('products', function (Blueprint $table) {
|
||||
// Add unit_id as foreign key to units table
|
||||
$table->foreignId('unit_id')->nullable()->after('weight_unit')->constrained('units')->nullOnDelete();
|
||||
$table->index('unit_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropForeign(['unit_id']);
|
||||
$table->dropIndex(['unit_id']);
|
||||
$table->dropColumn('unit_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('brands', function (Blueprint $table) {
|
||||
$table->string('banner_path')->nullable()->after('logo_path');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->dropColumn('banner_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('brands', function (Blueprint $table) {
|
||||
// Drop unique constraint from name column to allow soft-deleted brands with same name
|
||||
$table->dropUnique(['name']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
// Restore unique constraint on name column
|
||||
$table->unique('name');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('favorites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('product_id')->constrained()->onDelete('cascade');
|
||||
$table->timestamps();
|
||||
|
||||
// Ensure a user can only favorite a product once
|
||||
$table->unique(['user_id', 'product_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('favorites');
|
||||
}
|
||||
};
|
||||
722
resources/views/buyer/favorites/index.blade.php
Normal file
722
resources/views/buyer/favorites/index.blade.php
Normal file
@@ -0,0 +1,722 @@
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid py-6" x-data="browseProducts()" x-init="loadCartState()">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<span class="icon-[lucide--heart] size-8 text-error"></span>
|
||||
My Favorites
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">Your saved products for quick access</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('buyer.browse') }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--shopping-bag] size-5"></span>
|
||||
Continue Shopping
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Banner Carousel (hidden for favorites) -->
|
||||
@if(false)
|
||||
<div class="mb-8">
|
||||
<div class="carousel w-full rounded-lg shadow-lg" x-data="{ activeSlide: 0, slides: {{ $featuredProducts->count() }} }" x-init="setInterval(() => { activeSlide = (activeSlide + 1) % slides }, 5000)">
|
||||
@foreach($featuredProducts as $index => $featured)
|
||||
<div class="carousel-item relative w-full" x-show="activeSlide === {{ $index }}" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="relative bg-gradient-to-r from-primary to-primary-focus w-full min-h-[300px] overflow-hidden">
|
||||
<!-- Background Pattern -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-0 left-0 w-full h-full" style="background-image: url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="1"%3E%3Cpath d="M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hot Deal Badge (if featured) -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<div class="bg-error text-error-content px-4 py-2 rounded-full text-sm font-bold transform -rotate-12 shadow-lg">
|
||||
FEATURED
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center p-8 relative">
|
||||
<div class="flex-1 text-white z-10">
|
||||
@if($featured->brand)
|
||||
<div class="text-lg font-semibold mb-2 opacity-90">{{ $featured->brand->name }}</div>
|
||||
@endif
|
||||
<h2 class="text-4xl font-bold mb-4">{{ $featured->name }}</h2>
|
||||
<p class="text-lg mb-6 max-w-2xl opacity-90">
|
||||
{{ Str::limit($featured->description, 150) }}
|
||||
</p>
|
||||
<div class="flex gap-4 items-center mb-6">
|
||||
@if($featured->strain)
|
||||
<span class="badge badge-lg bg-white/20 backdrop-blur-sm text-white border-white/30">{{ ucfirst($featured->strain->type) }}</span>
|
||||
@endif
|
||||
@if($featured->thc_percentage)
|
||||
<span class="badge badge-lg bg-white/20 backdrop-blur-sm text-white border-white/30">THC {{ $featured->thc_percentage }}%</span>
|
||||
@endif
|
||||
</div>
|
||||
<a href="{{ route('buyer.brands.products.show', [$featured->brand->slug, $featured->slug ?? $featured->id]) }}" class="btn btn-warning btn-lg">
|
||||
<span class="icon-[lucide--shopping-cart] size-5"></span>
|
||||
Shop Now
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Product Image -->
|
||||
@php
|
||||
$featuredImage = $featured->images()->where('is_primary', true)->first()
|
||||
?? $featured->images()->first();
|
||||
@endphp
|
||||
@if($featuredImage)
|
||||
<div class="flex-shrink-0 hidden md:block" x-data="{ imageLoaded: false }">
|
||||
<img
|
||||
x-intersect.once="imageLoaded = true"
|
||||
:src="imageLoaded ? '{{ asset('storage/' . $featuredImage->path) }}' : ''"
|
||||
alt="{{ $featured->name }}"
|
||||
class="h-64 w-auto drop-shadow-2xl transition-opacity duration-300"
|
||||
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
loading="lazy">
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Price Badge -->
|
||||
<div class="absolute top-1/2 right-8 transform -translate-y-1/2 hidden lg:block">
|
||||
<div class="bg-warning text-warning-content rounded-full w-24 h-24 flex items-center justify-center shadow-xl">
|
||||
<div class="text-center">
|
||||
<div class="text-xs uppercase font-semibold">From</div>
|
||||
<div class="text-2xl font-bold">${{ number_format($featured->wholesale_price, 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<!-- Carousel Controls -->
|
||||
<div class="absolute flex justify-between transform -translate-y-1/2 left-5 right-5 top-1/2">
|
||||
<button @click="activeSlide = (activeSlide - 1 + slides) % slides" class="btn btn-circle btn-sm bg-white/20 backdrop-blur-sm border-white/30 text-white hover:bg-white/30">❮</button>
|
||||
<button @click="activeSlide = (activeSlide + 1) % slides" class="btn btn-circle btn-sm bg-white/20 backdrop-blur-sm border-white/30 text-white hover:bg-white/30">❯</button>
|
||||
</div>
|
||||
|
||||
<!-- Dots Indicator -->
|
||||
<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2">
|
||||
@foreach($featuredProducts as $index => $featured)
|
||||
<button @click="activeSlide = {{ $index }}" class="w-3 h-3 rounded-full transition-all" :class="activeSlide === {{ $index }} ? 'bg-white w-8' : 'bg-white/50'"></button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card bg-base-100 shadow-lg sticky top-6">
|
||||
<div class="card-body">
|
||||
<h3 class="font-bold text-lg mb-4">Filters</h3>
|
||||
|
||||
<form method="GET" action="{{ route('buyer.favorites.index') }}" id="filterForm">
|
||||
<!-- Search -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Search</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="Name, SKU, or description..."
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Brand Filter -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Brand</span>
|
||||
</label>
|
||||
<select name="brand_id" class="select select-bordered 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>
|
||||
</div>
|
||||
|
||||
<!-- Strain Type Filter -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Strain Type</span>
|
||||
</label>
|
||||
<select name="strain_type" class="select select-bordered w-full">
|
||||
<option value="">All Types</option>
|
||||
<option value="indica" {{ request('strain_type') == 'indica' ? 'selected' : '' }}>Indica</option>
|
||||
<option value="sativa" {{ request('strain_type') == 'sativa' ? 'selected' : '' }}>Sativa</option>
|
||||
<option value="hybrid" {{ request('strain_type') == 'hybrid' ? 'selected' : '' }}>Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Price Range Filter -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Price Range</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="price_min"
|
||||
value="{{ request('price_min') }}"
|
||||
placeholder="Min"
|
||||
step="0.01"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="price_max"
|
||||
value="{{ request('price_max') }}"
|
||||
placeholder="Max"
|
||||
step="0.01"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In Stock Only -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-medium">In Stock Only</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="in_stock"
|
||||
value="1"
|
||||
{{ request('in_stock') ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
<span class="icon-[lucide--filter] size-4"></span>
|
||||
Apply
|
||||
</button>
|
||||
<a href="{{ route('buyer.favorites.index') }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--x] size-4"></span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Grid -->
|
||||
<div class="lg:col-span-3">
|
||||
<!-- Sort and View Options -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold">{{ $products->firstItem() ?? 0 }}</span>
|
||||
to <span class="font-semibold">{{ $products->lastItem() ?? 0 }}</span>
|
||||
of <span class="font-semibold">{{ $products->total() }}</span> products
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<form method="GET" action="{{ route('buyer.favorites.index') }}" class="flex items-center gap-2">
|
||||
<!-- Preserve existing filters -->
|
||||
@foreach(request()->except('sort') as $key => $value)
|
||||
@if(is_array($value))
|
||||
@foreach($value as $v)
|
||||
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
|
||||
@endforeach
|
||||
@else
|
||||
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<label class="text-sm font-medium text-gray-700">Sort by:</label>
|
||||
<select name="sort" class="select select-bordered select-sm" onchange="this.form.submit()">
|
||||
<option value="name_asc" {{ request('sort') == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
|
||||
<option value="name_desc" {{ request('sort') == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
|
||||
<option value="price_asc" {{ request('sort') == 'price_asc' ? 'selected' : '' }}>Price (Low to High)</option>
|
||||
<option value="price_desc" {{ request('sort') == 'price_desc' ? 'selected' : '' }}>Price (High to Low)</option>
|
||||
<option value="newest" {{ request('sort', 'newest') == 'newest' ? 'selected' : '' }}>Newest First</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Products Grid -->
|
||||
@if($products->count() > 0)
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-6">
|
||||
@foreach($products as $product)
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<!-- Product Image -->
|
||||
<figure class="relative h-48 bg-gray-100" x-data="{ imageLoaded: false }">
|
||||
@php
|
||||
$productImage = $product->images()->where('is_primary', true)->first()
|
||||
?? $product->images()->first();
|
||||
@endphp
|
||||
@if($productImage)
|
||||
<img
|
||||
x-intersect.once="imageLoaded = true"
|
||||
:src="imageLoaded ? '{{ asset('storage/' . $productImage->path) }}' : ''"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-full h-full object-cover transition-opacity duration-300"
|
||||
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
loading="lazy"
|
||||
>
|
||||
<!-- Loading placeholder -->
|
||||
<div x-show="!imageLoaded" class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="loading loading-spinner loading-md text-primary"></span>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<span class="icon-[lucide--image] size-16 text-gray-300"></span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Remove from Favorites Button -->
|
||||
<button
|
||||
@click="removeFavorite({{ $product->id }})"
|
||||
class="absolute top-2 left-2 btn btn-circle btn-sm bg-error/90 hover:bg-error text-white border-0 shadow-md z-10 transition-all hover:scale-110"
|
||||
aria-label="Remove from favorites"
|
||||
title="Remove from favorites">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Stock Badge -->
|
||||
@if($product->quantity_on_hand > 0)
|
||||
<div class="badge badge-success absolute top-2 right-2">In Stock</div>
|
||||
@else
|
||||
<div class="badge badge-error absolute top-2 right-2">Out of Stock</div>
|
||||
@endif
|
||||
</figure>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Brand -->
|
||||
@if($product->brand)
|
||||
<div class="text-xs font-semibold uppercase tracking-wide">
|
||||
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}" class="text-primary hover:underline">
|
||||
{{ $product->brand->name }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Product Name -->
|
||||
<h3 class="card-title text-lg">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->slug ?? $product->id]) }}" class="hover:text-primary">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<!-- Strain Info -->
|
||||
@if($product->strain)
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span class="badge badge-sm badge-outline">{{ ucfirst($product->strain->type) }}</span>
|
||||
<span>{{ $product->strain->name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- THC/CBD Info -->
|
||||
@if($product->thc_percentage || $product->cbd_percentage)
|
||||
<div class="flex gap-3 text-xs text-gray-600">
|
||||
@if($product->thc_percentage)
|
||||
<span><strong>THC:</strong> {{ $product->thc_percentage }}%</span>
|
||||
@endif
|
||||
@if($product->cbd_percentage)
|
||||
<span><strong>CBD:</strong> {{ $product->cbd_percentage }}%</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Price -->
|
||||
<div class="text-2xl font-bold text-primary mt-2">
|
||||
${{ number_format($product->wholesale_price, 2) }}
|
||||
@if($product->price_unit)
|
||||
<span class="text-sm text-gray-500 font-normal">/ {{ $product->price_unit }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Stock Info -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-600">{{ $product->available_quantity }} units available</span>
|
||||
@if($product->available_quantity > 0 && $product->available_quantity <= 10)
|
||||
<span class="badge badge-warning badge-sm">Low Stock</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card-actions justify-between items-center mt-4"
|
||||
x-data="{ productId: {{ $product->id }}, availableQty: {{ $product->available_quantity }} }">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->slug ?? $product->id]) }}" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[lucide--eye] size-4"></span>
|
||||
Details
|
||||
</a>
|
||||
|
||||
@if($product->isInStock())
|
||||
<!-- Show "Add to Cart" button if not in cart -->
|
||||
<div x-show="!isInCart(productId)" x-cloak>
|
||||
<button @click="addToCart(productId)" class="btn btn-primary btn-sm">
|
||||
<span class="icon-[lucide--shopping-cart] size-4"></span>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Show quantity controls if in cart -->
|
||||
<div x-show="isInCart(productId)" x-cloak class="flex items-center gap-2">
|
||||
<button
|
||||
@click="updateQuantity(productId, -1)"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
title="Decrease quantity">
|
||||
<span class="icon-[lucide--minus] size-4"></span>
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-16 text-center"
|
||||
:value="getQuantity(productId)"
|
||||
@change="setQuantity(productId, $event.target.value)"
|
||||
:max="availableQty"
|
||||
min="1"
|
||||
step="1"
|
||||
>
|
||||
<button
|
||||
@click="updateQuantity(productId, 1)"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
:disabled="getQuantity(productId) >= availableQty"
|
||||
:class="{ 'btn-disabled': getQuantity(productId) >= availableQty }"
|
||||
title="Increase quantity">
|
||||
<span class="icon-[lucide--plus] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<button disabled class="btn btn-disabled btn-sm">Out of Stock</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex justify-center">
|
||||
{{ $products->appends(request()->except('page'))->links() }}
|
||||
</div>
|
||||
@else
|
||||
<!-- Empty State -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body text-center py-16">
|
||||
<span class="icon-[lucide--heart] size-16 text-gray-300 mx-auto mb-4"></span>
|
||||
<h3 class="text-xl font-semibold text-gray-700 mb-2">No favorites yet</h3>
|
||||
<p class="text-gray-500 mb-4">Start browsing products and click the heart icon to save your favorites</p>
|
||||
<a href="{{ route('buyer.browse') }}" class="btn btn-primary btn-sm">
|
||||
<span class="icon-[lucide--search] size-4"></span>
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function browseProducts() {
|
||||
return {
|
||||
// Cart state: Map of productId -> { cartId, quantity }
|
||||
cart: {},
|
||||
|
||||
// Debounce timers for each product (prevents race conditions on rapid clicks)
|
||||
updateTimers: {},
|
||||
|
||||
/**
|
||||
* Load current cart state from server
|
||||
*/
|
||||
async loadCartState() {
|
||||
try {
|
||||
// Fetch full cart data to get product IDs and quantities
|
||||
const cartResponse = await fetch('/b/cart', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (cartResponse.ok) {
|
||||
// Check if response is actually JSON
|
||||
const contentType = cartResponse.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
console.warn('Cart endpoint returned non-JSON response, initializing empty cart');
|
||||
this.cart = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const cartData = await cartResponse.json();
|
||||
console.log('Loaded cart state:', cartData);
|
||||
|
||||
// Build cart state map
|
||||
const newCart = {};
|
||||
if (cartData.items && Array.isArray(cartData.items)) {
|
||||
cartData.items.forEach(item => {
|
||||
newCart[item.product_id] = {
|
||||
cartId: item.id,
|
||||
quantity: item.quantity
|
||||
};
|
||||
});
|
||||
}
|
||||
this.cart = newCart;
|
||||
console.log('Cart state initialized:', this.cart);
|
||||
} else {
|
||||
console.warn('Failed to load cart state, status:', cartResponse.status);
|
||||
this.cart = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading cart state:', error.message);
|
||||
// Initialize empty cart on error
|
||||
this.cart = {};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if product is in cart
|
||||
*/
|
||||
isInCart(productId) {
|
||||
return this.cart.hasOwnProperty(productId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get quantity for product
|
||||
*/
|
||||
getQuantity(productId) {
|
||||
return this.cart[productId]?.quantity || 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total cart count (sum of all quantities)
|
||||
*/
|
||||
getCartCount() {
|
||||
return Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add product to cart (initial add with qty=1) - optimistic update
|
||||
*/
|
||||
async addToCart(productId) {
|
||||
// OPTIMISTIC UPDATE: Add to cart immediately (UI updates instantly)
|
||||
this.cart = {
|
||||
...this.cart,
|
||||
[productId]: {
|
||||
cartId: 'pending', // Temporary until we get real ID
|
||||
quantity: 1
|
||||
}
|
||||
};
|
||||
|
||||
// Dispatch event for optimistic cart badge update
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', 1);
|
||||
|
||||
const response = await window.axios.post('/b/cart/add', formData);
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
// Sync with server - update with real cart ID
|
||||
this.cart = {
|
||||
...this.cart,
|
||||
[productId]: {
|
||||
cartId: data.cart_item.id,
|
||||
quantity: data.cart_item.quantity
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// ROLLBACK: Remove optimistic update
|
||||
const newCart = { ...this.cart };
|
||||
delete newCart[productId];
|
||||
this.cart = newCart;
|
||||
this.showError(data.message || 'Failed to add to cart');
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
// ROLLBACK: Remove optimistic update
|
||||
const newCart = { ...this.cart };
|
||||
delete newCart[productId];
|
||||
this.cart = newCart;
|
||||
console.error('Error adding to cart:', error);
|
||||
const message = error.response?.data?.message || 'Failed to add product to cart. Please try again.';
|
||||
this.showError(message);
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update quantity (increment/decrement)
|
||||
*/
|
||||
async updateQuantity(productId, delta) {
|
||||
const currentQty = this.getQuantity(productId);
|
||||
const newQty = Math.max(0, currentQty + delta);
|
||||
await this.setQuantity(productId, newQty);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set specific quantity - optimistic update with debouncing
|
||||
*/
|
||||
async setQuantity(productId, quantity) {
|
||||
quantity = Math.max(0, parseInt(quantity));
|
||||
|
||||
if (!this.isInCart(productId)) return;
|
||||
|
||||
const cartItem = this.cart[productId];
|
||||
|
||||
// If quantity is 0, remove from cart
|
||||
if (quantity === 0) {
|
||||
await this.removeFromCart(productId);
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update quantity immediately (feels instant)
|
||||
this.cart = {
|
||||
...this.cart,
|
||||
[productId]: {
|
||||
...this.cart[productId],
|
||||
quantity: quantity
|
||||
}
|
||||
};
|
||||
|
||||
// Dispatch event for optimistic cart badge update
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
|
||||
// DEBOUNCE: Cancel previous pending request for this product
|
||||
if (this.updateTimers[productId]) {
|
||||
clearTimeout(this.updateTimers[productId]);
|
||||
}
|
||||
|
||||
// Wait 300ms after last click, then send ONE request with final value
|
||||
this.updateTimers[productId] = setTimeout(async () => {
|
||||
const finalQuantity = this.cart[productId]?.quantity || quantity;
|
||||
|
||||
try {
|
||||
const response = await window.axios.patch(`/b/cart/${cartItem.cartId}`, { quantity: finalQuantity });
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
// Sync with server response
|
||||
this.cart = {
|
||||
...this.cart,
|
||||
[productId]: {
|
||||
...this.cart[productId],
|
||||
quantity: data.cart_item.quantity
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// ROLLBACK: Use server's valid quantity
|
||||
this.cart = {
|
||||
...this.cart,
|
||||
[productId]: {
|
||||
...this.cart[productId],
|
||||
quantity: data.cart_item?.quantity || finalQuantity
|
||||
}
|
||||
};
|
||||
this.showError(data.message || 'Failed to update quantity');
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating quantity:', error);
|
||||
const message = error.response?.data?.message || 'Failed to update quantity. Please try again.';
|
||||
this.showError(message);
|
||||
// Reload cart state from server
|
||||
await this.loadCartState();
|
||||
}
|
||||
}, 300); // Wait 300ms after last interaction
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove product from cart
|
||||
*/
|
||||
async removeFromCart(productId) {
|
||||
if (!this.isInCart(productId)) return;
|
||||
|
||||
const cartItem = this.cart[productId];
|
||||
|
||||
try {
|
||||
const response = await window.axios.delete(`/b/cart/${cartItem.cartId}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
// Remove from local state - trigger reactivity
|
||||
const newCart = { ...this.cart };
|
||||
delete newCart[productId];
|
||||
this.cart = newCart;
|
||||
// Dispatch event for cart badge update
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing from cart:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show error message to user
|
||||
*/
|
||||
showError(message) {
|
||||
// Use global toast function
|
||||
window.showToast(message, 'error');
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove product from favorites and refresh page
|
||||
*/
|
||||
async removeFavorite(productId) {
|
||||
if (!confirm('Remove this product from your favorites?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/b/favorites/${productId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
window.showToast(data.message || 'Removed from favorites', 'success');
|
||||
|
||||
// Reload page to update the list
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
window.showToast('Failed to remove from favorites', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing favorite:', error);
|
||||
window.showToast('Failed to remove from favorites', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -266,6 +266,17 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Favorite Button -->
|
||||
<button
|
||||
@click="toggleFavorite({{ $product->id }})"
|
||||
class="absolute top-2 left-2 btn btn-circle btn-sm bg-white/90 hover:bg-white border-0 shadow-md z-10 transition-all"
|
||||
:class="isFavorited({{ $product->id }}) ? 'text-error scale-110' : 'text-gray-400 hover:text-error'"
|
||||
aria-label="Toggle favorite">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="isFavorited({{ $product->id }}) ? 'fill-current' : ''">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Stock Badge -->
|
||||
@if($product->quantity_on_hand > 0)
|
||||
<div class="badge badge-success absolute top-2 right-2">In Stock</div>
|
||||
@@ -408,6 +419,9 @@ function browseProducts() {
|
||||
// Cart state: Map of productId -> { cartId, quantity }
|
||||
cart: {},
|
||||
|
||||
// Favorites state: Set of product IDs
|
||||
favorites: new Set(@json(auth()->user()->favoriteProducts->pluck('id') ?? [])),
|
||||
|
||||
// Debounce timers for each product (prevents race conditions on rapid clicks)
|
||||
updateTimers: {},
|
||||
|
||||
@@ -657,6 +671,46 @@ function browseProducts() {
|
||||
showError(message) {
|
||||
// Use global toast function
|
||||
window.showToast(message, 'error');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if product is favorited
|
||||
*/
|
||||
isFavorited(productId) {
|
||||
return this.favorites.has(productId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle favorite status for a product
|
||||
*/
|
||||
async toggleFavorite(productId) {
|
||||
try {
|
||||
const response = await fetch(`/b/favorites/${productId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.favorited) {
|
||||
this.favorites.add(productId);
|
||||
window.showToast(data.message || 'Added to favorites', 'success');
|
||||
} else {
|
||||
this.favorites.delete(productId);
|
||||
window.showToast(data.message || 'Removed from favorites', 'info');
|
||||
}
|
||||
} else {
|
||||
window.showToast('Failed to update favorites', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
window.showToast('Failed to update favorites', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
menuInventory: $persist(true).as('sidebar-menu-inventory'),
|
||||
menuCustomers: $persist(false).as('sidebar-menu-customers'),
|
||||
menuFleet: $persist(true).as('sidebar-menu-fleet'),
|
||||
menuBusiness: $persist(true).as('sidebar-menu-business')
|
||||
menuBusiness: $persist(true).as('sidebar-menu-business'),
|
||||
menuSettings: $persist(true).as('sidebar-menu-settings')
|
||||
}">
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Overview</p>
|
||||
<div class="group collapse">
|
||||
@@ -291,7 +292,45 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuSettings" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--settings] size-4"></span>
|
||||
<span class="grow">Settings</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
@if($sidebarBusiness)
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.brands.*') ? 'active' : '' }}" href="{{ route('seller.business.brands.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Brands</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.product-categories.*') ? 'active' : '' }}" href="{{ route('seller.business.product-categories.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Product Categories</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.component-categories.*') ? 'active' : '' }}" href="{{ route('seller.business.component-categories.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Component Categories</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.units.*') ? 'active' : '' }}" href="{{ route('seller.business.units.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Units</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 px-3 py-2">
|
||||
<p class="text-xs text-base-content/60">Complete your business profile to access settings</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="from-base-100/60 pointer-events-none absolute start-0 end-0 bottom-0 h-7 bg-gradient-to-t to-transparent"></div>
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
<span class="icon-[lucide--palette] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
|
||||
</button>
|
||||
|
||||
<!-- Favorites/Wishlist -->
|
||||
<a href="{{ route('buyer.favorites.index') }}"
|
||||
class="btn btn-sm btn-circle btn-ghost relative"
|
||||
aria-label="Favorites">
|
||||
<span class="icon-[lucide--heart] size-5"></span>
|
||||
</a>
|
||||
|
||||
<!-- Shopping Cart -->
|
||||
<div class="relative" x-data="cartCounter()">
|
||||
<a href="{{ route('buyer.cart.index') }}" class="btn btn-sm btn-circle btn-ghost relative" aria-label="Shopping Cart">
|
||||
|
||||
343
resources/views/seller/brands/create.blade.php
Normal file
343
resources/views/seller/brands/create.blade.php
Normal file
@@ -0,0 +1,343 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--package-plus] size-8"></span>
|
||||
Create Brand
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Add a new brand to your business</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.brands.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Brands
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.brands.store', $business->slug) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Name -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Brand Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value="{{ old('name') }}"
|
||||
placeholder="e.g., Green Mountain, Pacific Coast, etc."
|
||||
required
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
/>
|
||||
@error('name')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Tagline -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="tagline">
|
||||
<span class="label-text font-medium">Tagline</span>
|
||||
</label>
|
||||
<input
|
||||
id="tagline"
|
||||
name="tagline"
|
||||
type="text"
|
||||
value="{{ old('tagline') }}"
|
||||
placeholder="A short catchy phrase for your brand"
|
||||
class="input input-bordered w-full @error('tagline') input-error @enderror"
|
||||
/>
|
||||
@error('tagline')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- SKU Prefix -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sku_prefix">
|
||||
<span class="label-text font-medium">SKU Prefix</span>
|
||||
</label>
|
||||
<input
|
||||
id="sku_prefix"
|
||||
name="sku_prefix"
|
||||
type="text"
|
||||
value="{{ old('sku_prefix') }}"
|
||||
placeholder="e.g., GM, PC"
|
||||
maxlength="10"
|
||||
class="input input-bordered w-full @error('sku_prefix') input-error @enderror"
|
||||
/>
|
||||
@error('sku_prefix')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Optional prefix for product SKUs (letters and numbers only)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sort_order">
|
||||
<span class="label-text font-medium">Sort Order</span>
|
||||
</label>
|
||||
<input
|
||||
id="sort_order"
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value="{{ old('sort_order', 0) }}"
|
||||
min="0"
|
||||
class="input input-bordered w-full @error('sort_order') input-error @enderror"
|
||||
/>
|
||||
@error('sort_order')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Lower numbers appear first</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
placeholder="Tell customers about your brand"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
>{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Brand Logo -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="logo">
|
||||
<span class="label-text font-medium">Brand Logo</span>
|
||||
</label>
|
||||
<input
|
||||
id="logo"
|
||||
name="logo"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp,image/svg+xml,image/gif"
|
||||
class="file-input file-input-bordered w-full @error('logo') file-input-error @enderror"
|
||||
/>
|
||||
@error('logo')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Square logo recommended. Max: 2MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Banner -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="banner">
|
||||
<span class="label-text font-medium">Brand Banner</span>
|
||||
</label>
|
||||
<input
|
||||
id="banner"
|
||||
name="banner"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp,image/gif"
|
||||
class="file-input file-input-bordered w-full @error('banner') file-input-error @enderror"
|
||||
/>
|
||||
@error('banner')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Wide banner (1200x400 recommended). Max: 5MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Website URL -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="website_url">
|
||||
<span class="label-text font-medium">Website URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="website_url"
|
||||
name="website_url"
|
||||
type="url"
|
||||
value="{{ old('website_url') }}"
|
||||
placeholder="https://www.yourbrand.com"
|
||||
class="input input-bordered w-full @error('website_url') input-error @enderror"
|
||||
/>
|
||||
@error('website_url')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Social Media Section -->
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[lucide--share-2] size-5"></span>
|
||||
Social Media
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Instagram -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="instagram_handle">
|
||||
<span class="label-text font-medium">Instagram Handle</span>
|
||||
</label>
|
||||
<input
|
||||
id="instagram_handle"
|
||||
name="instagram_handle"
|
||||
type="text"
|
||||
value="{{ old('instagram_handle') }}"
|
||||
placeholder="@yourbrand"
|
||||
class="input input-bordered w-full @error('instagram_handle') input-error @enderror"
|
||||
/>
|
||||
@error('instagram_handle')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Twitter -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="twitter_handle">
|
||||
<span class="label-text font-medium">Twitter/X Handle</span>
|
||||
</label>
|
||||
<input
|
||||
id="twitter_handle"
|
||||
name="twitter_handle"
|
||||
type="text"
|
||||
value="{{ old('twitter_handle') }}"
|
||||
placeholder="@yourbrand"
|
||||
class="input input-bordered w-full @error('twitter_handle') input-error @enderror"
|
||||
/>
|
||||
@error('twitter_handle')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Facebook URL -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="facebook_url">
|
||||
<span class="label-text font-medium">Facebook URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="facebook_url"
|
||||
name="facebook_url"
|
||||
type="url"
|
||||
value="{{ old('facebook_url') }}"
|
||||
placeholder="https://www.facebook.com/yourbrand"
|
||||
class="input input-bordered w-full @error('facebook_url') input-error @enderror"
|
||||
/>
|
||||
@error('facebook_url')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[lucide--settings] size-5"></span>
|
||||
Settings
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
id="is_active"
|
||||
value="1"
|
||||
{{ old('is_active', true) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Active</span>
|
||||
<p class="text-xs text-base-content/60">Brand is active and can be used</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Public -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_public"
|
||||
id="is_public"
|
||||
value="1"
|
||||
{{ old('is_public', true) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Public</span>
|
||||
<p class="text-xs text-base-content/60">Show brand on public marketplace</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Featured -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_featured"
|
||||
id="is_featured"
|
||||
value="1"
|
||||
{{ old('is_featured', false) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Featured Brand</span>
|
||||
<p class="text-xs text-base-content/60">Highlight this brand in featured sections</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Create Brand
|
||||
</button>
|
||||
<a href="{{ route('seller.business.brands.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
405
resources/views/seller/brands/edit.blade.php
Normal file
405
resources/views/seller/brands/edit.blade.php
Normal file
@@ -0,0 +1,405 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--package-open] size-8"></span>
|
||||
Edit Brand
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Update brand information</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.brands.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Brands
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.brands.update', [$business->slug, $brand->slug]) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Name -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Brand Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value="{{ old('name', $brand->name) }}"
|
||||
placeholder="e.g., Green Mountain, Pacific Coast, etc."
|
||||
required
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
/>
|
||||
@error('name')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Tagline -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="tagline">
|
||||
<span class="label-text font-medium">Tagline</span>
|
||||
</label>
|
||||
<input
|
||||
id="tagline"
|
||||
name="tagline"
|
||||
type="text"
|
||||
value="{{ old('tagline', $brand->tagline) }}"
|
||||
placeholder="A short catchy phrase for your brand"
|
||||
class="input input-bordered w-full @error('tagline') input-error @enderror"
|
||||
/>
|
||||
@error('tagline')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- SKU Prefix -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sku_prefix">
|
||||
<span class="label-text font-medium">SKU Prefix</span>
|
||||
</label>
|
||||
<input
|
||||
id="sku_prefix"
|
||||
name="sku_prefix"
|
||||
type="text"
|
||||
value="{{ old('sku_prefix', $brand->sku_prefix) }}"
|
||||
placeholder="e.g., GM, PC"
|
||||
maxlength="10"
|
||||
class="input input-bordered w-full @error('sku_prefix') input-error @enderror"
|
||||
/>
|
||||
@error('sku_prefix')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Optional prefix for product SKUs (letters and numbers only)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sort_order">
|
||||
<span class="label-text font-medium">Sort Order</span>
|
||||
</label>
|
||||
<input
|
||||
id="sort_order"
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value="{{ old('sort_order', $brand->sort_order) }}"
|
||||
min="0"
|
||||
class="input input-bordered w-full @error('sort_order') input-error @enderror"
|
||||
/>
|
||||
@error('sort_order')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Lower numbers appear first</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
placeholder="Tell customers about your brand"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
>{{ old('description', $brand->description) }}</textarea>
|
||||
@error('description')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Brand Logo -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="logo">
|
||||
<span class="label-text font-medium">Brand Logo</span>
|
||||
</label>
|
||||
|
||||
@if($brand->hasLogo())
|
||||
<div class="mb-4 flex items-start gap-4">
|
||||
<div class="avatar">
|
||||
<div class="w-32 rounded">
|
||||
<img src="{{ $brand->getLogoUrl() }}" alt="{{ $brand->name }}" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-base-content/70 mb-2">Current logo</p>
|
||||
<p class="text-xs text-base-content/50 mb-2 font-mono">{{ $brand->logo_path }}</p>
|
||||
<label class="label cursor-pointer justify-start gap-3 w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remove_logo"
|
||||
id="remove_logo"
|
||||
value="1"
|
||||
class="checkbox checkbox-error checkbox-sm"
|
||||
/>
|
||||
<span class="label-text text-sm">Remove current logo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input
|
||||
id="logo"
|
||||
name="logo"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp,image/svg+xml,image/gif"
|
||||
class="file-input file-input-bordered w-full @error('logo') file-input-error @enderror"
|
||||
/>
|
||||
@error('logo')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Square logo recommended. Max: 2MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Banner -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="banner">
|
||||
<span class="label-text font-medium">Brand Banner</span>
|
||||
</label>
|
||||
|
||||
@if($brand->hasBanner())
|
||||
<div class="mb-4">
|
||||
<div class="mb-2">
|
||||
<img src="{{ $brand->getBannerUrl() }}" alt="{{ $brand->name }} banner" class="rounded max-w-full h-auto" style="max-height: 200px;" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70 mb-2">Current banner</p>
|
||||
<p class="text-xs text-base-content/50 mb-2 font-mono">{{ $brand->banner_path }}</p>
|
||||
<label class="label cursor-pointer justify-start gap-3 w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remove_banner"
|
||||
id="remove_banner"
|
||||
value="1"
|
||||
class="checkbox checkbox-error checkbox-sm"
|
||||
/>
|
||||
<span class="label-text text-sm">Remove current banner</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input
|
||||
id="banner"
|
||||
name="banner"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp,image/gif"
|
||||
class="file-input file-input-bordered w-full @error('banner') file-input-error @enderror"
|
||||
/>
|
||||
@error('banner')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Wide banner (1200x400 recommended). Max: 5MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Website URL -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="website_url">
|
||||
<span class="label-text font-medium">Website URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="website_url"
|
||||
name="website_url"
|
||||
type="url"
|
||||
value="{{ old('website_url', $brand->website_url) }}"
|
||||
placeholder="https://www.yourbrand.com"
|
||||
class="input input-bordered w-full @error('website_url') input-error @enderror"
|
||||
/>
|
||||
@error('website_url')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Social Media Section -->
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[lucide--share-2] size-5"></span>
|
||||
Social Media
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Instagram -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="instagram_handle">
|
||||
<span class="label-text font-medium">Instagram Handle</span>
|
||||
</label>
|
||||
<input
|
||||
id="instagram_handle"
|
||||
name="instagram_handle"
|
||||
type="text"
|
||||
value="{{ old('instagram_handle', $brand->instagram_handle) }}"
|
||||
placeholder="@yourbrand"
|
||||
class="input input-bordered w-full @error('instagram_handle') input-error @enderror"
|
||||
/>
|
||||
@error('instagram_handle')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Twitter -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="twitter_handle">
|
||||
<span class="label-text font-medium">Twitter/X Handle</span>
|
||||
</label>
|
||||
<input
|
||||
id="twitter_handle"
|
||||
name="twitter_handle"
|
||||
type="text"
|
||||
value="{{ old('twitter_handle', $brand->twitter_handle) }}"
|
||||
placeholder="@yourbrand"
|
||||
class="input input-bordered w-full @error('twitter_handle') input-error @enderror"
|
||||
/>
|
||||
@error('twitter_handle')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Facebook URL -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="facebook_url">
|
||||
<span class="label-text font-medium">Facebook URL</span>
|
||||
</label>
|
||||
<input
|
||||
id="facebook_url"
|
||||
name="facebook_url"
|
||||
type="url"
|
||||
value="{{ old('facebook_url', $brand->facebook_url) }}"
|
||||
placeholder="https://www.facebook.com/yourbrand"
|
||||
class="input input-bordered w-full @error('facebook_url') input-error @enderror"
|
||||
/>
|
||||
@error('facebook_url')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="md:col-span-2">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span class="icon-[lucide--settings] size-5"></span>
|
||||
Settings
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Is Active -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
id="is_active"
|
||||
value="1"
|
||||
{{ old('is_active', $brand->is_active) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Active</span>
|
||||
<p class="text-xs text-base-content/60">Brand is active and can be used</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Public -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_public"
|
||||
id="is_public"
|
||||
value="1"
|
||||
{{ old('is_public', $brand->is_public) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Public</span>
|
||||
<p class="text-xs text-base-content/60">Show brand on public marketplace</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Is Featured -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_featured"
|
||||
id="is_featured"
|
||||
value="1"
|
||||
{{ old('is_featured', $brand->is_featured) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Featured Brand</span>
|
||||
<p class="text-xs text-base-content/60">Highlight this brand in featured sections</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Brand Stats -->
|
||||
@if($brand->products->count() > 0)
|
||||
<div class="md:col-span-2">
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-[lucide--info] size-5"></span>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">Brand Information</p>
|
||||
<p>This brand has {{ $brand->products->count() }} products.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Update Brand
|
||||
</button>
|
||||
<a href="{{ route('seller.business.brands.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
498
resources/views/seller/brands/index.blade.php
Normal file
498
resources/views/seller/brands/index.blade.php
Normal file
@@ -0,0 +1,498 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div x-data="brandsManager()">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--package] size-8"></span>
|
||||
Brands
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Manage your product brands</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-4.5"></span>
|
||||
Add Brand
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<!-- Search Input -->
|
||||
<div class="lg:col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterBrands()"
|
||||
placeholder="Search brands..."
|
||||
class="input input-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<select x-model="statusFilter" @change="filterBrands()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Filter -->
|
||||
<div>
|
||||
<select x-model="visibilityFilter" @change="filterBrands()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Visibility</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Featured Filter -->
|
||||
<div>
|
||||
<select x-model="featuredFilter" @change="filterBrands()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Featured</option>
|
||||
<option value="yes">Featured</option>
|
||||
<option value="no">Not Featured</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Showing <span class="font-semibold text-base-content" x-text="paginatedBrands.length"></span> of
|
||||
<span class="font-semibold text-base-content" x-text="filteredBrands.length"></span>
|
||||
<template x-if="filteredBrands.length !== allBrands.length">
|
||||
<span class="text-primary">(filtered from <span x-text="allBrands.length"></span> total)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<template x-if="searchQuery || statusFilter || visibilityFilter || featuredFilter">
|
||||
<button @click="clearFilters()" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[lucide--x] size-4"></span>
|
||||
Clear Filters
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="selectedBrands.length > 0" x-cloak class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="icon-[lucide--check-square] size-5"></span>
|
||||
<span class="font-medium" x-text="`${selectedBrands.length} selected`"></span>
|
||||
</div>
|
||||
<button @click="bulkDelete()" type="button" class="btn btn-error btn-sm">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Form (hidden) -->
|
||||
<form x-ref="bulkDeleteForm" action="{{ route('seller.business.brands.bulk-delete', $business->slug) }}" method="POST" style="display: none;">
|
||||
@csrf
|
||||
<template x-for="brandId in selectedBrands" :key="brandId">
|
||||
<input type="hidden" name="brand_ids[]" :value="brandId">
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<!-- Brands Table -->
|
||||
<div class="card bg-base-100 shadow overflow-visible">
|
||||
<div class="card-body p-0 overflow-visible">
|
||||
<template x-if="allBrands.length > 0">
|
||||
<div class="overflow-x-auto overflow-y-visible">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" @change="toggleSelectAll()" :checked="isAllSelected()" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</th>
|
||||
<th>Logo</th>
|
||||
<th @click="sortBy('name')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Name
|
||||
<span x-show="sortColumn === 'name'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>SKU Prefix</th>
|
||||
<th @click="sortBy('is_active')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Status
|
||||
<span x-show="sortColumn === 'is_active'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Visibility</th>
|
||||
<th>Featured</th>
|
||||
<th>Products</th>
|
||||
<th @click="sortBy('sort_order')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Sort Order
|
||||
<span x-show="sortColumn === 'sort_order'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="brand in paginatedBrands" :key="brand.id">
|
||||
<tr>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" :value="brand.id" @change="toggleBrandSelection(brand.id)" :checked="selectedBrands.includes(brand.id)" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="brand.logo_url">
|
||||
<div class="avatar">
|
||||
<div class="w-12 h-12 rounded">
|
||||
<img :src="brand.logo_url" :alt="brand.name" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!brand.logo_url">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded w-12">
|
||||
<span class="text-xs" x-text="brand.name.substring(0, 2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<div class="font-semibold" x-text="brand.name"></div>
|
||||
<template x-if="brand.tagline">
|
||||
<div class="text-xs text-base-content/60" x-text="brand.tagline"></div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="brand.sku_prefix">
|
||||
<span class="badge badge-neutral" x-text="brand.sku_prefix"></span>
|
||||
</template>
|
||||
<template x-if="!brand.sku_prefix">
|
||||
<span class="text-base-content/40">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="brand.is_active">
|
||||
<span class="badge badge-success">Active</span>
|
||||
</template>
|
||||
<template x-if="!brand.is_active">
|
||||
<span class="badge badge-error">Inactive</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="brand.is_public">
|
||||
<span class="badge badge-primary">Public</span>
|
||||
</template>
|
||||
<template x-if="!brand.is_public">
|
||||
<span class="badge badge-ghost">Private</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="brand.is_featured">
|
||||
<span class="icon-[lucide--star] size-5 text-warning"></span>
|
||||
</template>
|
||||
<template x-if="!brand.is_featured">
|
||||
<span class="text-base-content/20">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline" x-text="brand.products_count"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm" x-text="brand.sort_order"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li>
|
||||
<a :href="`/s/{{ $business->slug }}/brands/${brand.slug}/edit`">
|
||||
<span class="icon-[lucide--edit] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="deleteBrand(brand)" class="text-error">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allBrands.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--package] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No brands found</h3>
|
||||
<p class="text-base-content/60 mb-4">Get started by creating your first brand</p>
|
||||
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-5"></span>
|
||||
Add Brand
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allBrands.length > 0 && filteredBrands.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--search] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template x-if="filteredBrands.length > perPage">
|
||||
<div class="border-t border-base-300 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<button @click="goToPage(currentPage - 1)" :disabled="currentPage === 1" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-left] size-4"></span>
|
||||
</button>
|
||||
<template x-for="page in visiblePages" :key="page">
|
||||
<button @click="goToPage(page)" :class="{ 'btn-active': page === currentPage }" class="join-item btn btn-sm" x-text="page"></button>
|
||||
</template>
|
||||
<button @click="goToPage(currentPage + 1)" :disabled="currentPage === totalPages" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-right] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<select x-model="perPage" @change="updatePagination()" class="select select-bordered select-sm">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="25">25 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
<option value="100">100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form x-ref="deleteForm" method="POST" style="display: none;">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function brandsManager() {
|
||||
return {
|
||||
allBrands: @json($brandsData),
|
||||
filteredBrands: [],
|
||||
paginatedBrands: [],
|
||||
searchQuery: '',
|
||||
statusFilter: '',
|
||||
visibilityFilter: '',
|
||||
featuredFilter: '',
|
||||
sortColumn: 'sort_order',
|
||||
sortDirection: 'asc',
|
||||
selectedBrands: [],
|
||||
currentPage: 1,
|
||||
perPage: 25,
|
||||
totalPages: 1,
|
||||
visiblePages: [],
|
||||
|
||||
init() {
|
||||
this.filteredBrands = [...this.allBrands];
|
||||
this.sortBrands();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
filterBrands() {
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
|
||||
this.filteredBrands = this.allBrands.filter(brand => {
|
||||
// Search filter
|
||||
const matchesSearch = !query ||
|
||||
brand.name.toLowerCase().includes(query) ||
|
||||
(brand.tagline && brand.tagline.toLowerCase().includes(query)) ||
|
||||
(brand.sku_prefix && brand.sku_prefix.toLowerCase().includes(query));
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = !this.statusFilter ||
|
||||
(this.statusFilter === 'active' && brand.is_active) ||
|
||||
(this.statusFilter === 'inactive' && !brand.is_active);
|
||||
|
||||
// Visibility filter
|
||||
const matchesVisibility = !this.visibilityFilter ||
|
||||
(this.visibilityFilter === 'public' && brand.is_public) ||
|
||||
(this.visibilityFilter === 'private' && !brand.is_public);
|
||||
|
||||
// Featured filter
|
||||
const matchesFeatured = !this.featuredFilter ||
|
||||
(this.featuredFilter === 'yes' && brand.is_featured) ||
|
||||
(this.featuredFilter === 'no' && !brand.is_featured);
|
||||
|
||||
return matchesSearch && matchesStatus && matchesVisibility && matchesFeatured;
|
||||
});
|
||||
|
||||
this.sortBrands();
|
||||
this.currentPage = 1;
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.searchQuery = '';
|
||||
this.statusFilter = '';
|
||||
this.visibilityFilter = '';
|
||||
this.featuredFilter = '';
|
||||
this.filterBrands();
|
||||
},
|
||||
|
||||
sortBy(column) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.sortBrands();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
sortBrands() {
|
||||
this.filteredBrands.sort((a, b) => {
|
||||
let aVal = a[this.sortColumn];
|
||||
let bVal = b[this.sortColumn];
|
||||
|
||||
if (this.sortColumn === 'created_at' || this.sortColumn === 'updated_at') {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
} else if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
updatePagination() {
|
||||
this.totalPages = Math.ceil(this.filteredBrands.length / this.perPage);
|
||||
if (this.currentPage > this.totalPages) {
|
||||
this.currentPage = Math.max(1, this.totalPages);
|
||||
}
|
||||
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = start + this.perPage;
|
||||
this.paginatedBrands = this.filteredBrands.slice(start, end);
|
||||
|
||||
this.updateVisiblePages();
|
||||
},
|
||||
|
||||
updateVisiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
let startPage = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(this.totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage < maxVisible - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
this.visiblePages = pages;
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.updatePagination();
|
||||
}
|
||||
},
|
||||
|
||||
toggleBrandSelection(brandId) {
|
||||
const index = this.selectedBrands.indexOf(brandId);
|
||||
if (index > -1) {
|
||||
this.selectedBrands.splice(index, 1);
|
||||
} else {
|
||||
this.selectedBrands.push(brandId);
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selectedBrands = [];
|
||||
} else {
|
||||
this.selectedBrands = this.paginatedBrands.map(b => b.id);
|
||||
}
|
||||
},
|
||||
|
||||
isAllSelected() {
|
||||
return this.paginatedBrands.length > 0 &&
|
||||
this.paginatedBrands.every(b => this.selectedBrands.includes(b.id));
|
||||
},
|
||||
|
||||
deleteBrand(brand) {
|
||||
if (!confirm('Are you sure you want to delete this brand?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const businessSlug = '{{ $business->slug }}';
|
||||
const form = this.$refs.deleteForm;
|
||||
form.action = `/s/${businessSlug}/brands/${brand.slug}`;
|
||||
form.submit();
|
||||
},
|
||||
|
||||
bulkDelete() {
|
||||
if (!confirm(`Are you sure you want to delete ${this.selectedBrands.length} brand(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.bulkDeleteForm.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@endsection
|
||||
150
resources/views/seller/component-categories/create.blade.php
Normal file
150
resources/views/seller/component-categories/create.blade.php
Normal file
@@ -0,0 +1,150 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-plus] size-8"></span>
|
||||
Create Component Category
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Add a new category to organize your components</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.component-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Categories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.component-categories.store', $business->slug) }}">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Name -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Category Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value="{{ old('name') }}"
|
||||
placeholder="e.g., Raw Materials, Packaging, Hardware"
|
||||
required
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
/>
|
||||
@error('name')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Parent Category -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="parent_id">
|
||||
<span class="label-text font-medium">Parent Category</span>
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
class="select select-bordered w-full @error('parent_id') select-error @enderror"
|
||||
>
|
||||
<option value="">None (Root Category)</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ old('parent_id') == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('parent_id')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Leave empty to create a top-level category</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sort_order">
|
||||
<span class="label-text font-medium">Sort Order</span>
|
||||
</label>
|
||||
<input
|
||||
id="sort_order"
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value="{{ old('sort_order', 0) }}"
|
||||
min="0"
|
||||
class="input input-bordered w-full @error('sort_order') input-error @enderror"
|
||||
/>
|
||||
@error('sort_order')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Lower numbers appear first</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
placeholder="Optional description of this category"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
>{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Public -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="public"
|
||||
id="public"
|
||||
value="1"
|
||||
{{ old('public', true) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Public Category</span>
|
||||
<p class="text-xs text-base-content/60">Make this category visible to buyers in the marketplace</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Create Category
|
||||
</button>
|
||||
<a href="{{ route('seller.business.component-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
164
resources/views/seller/component-categories/edit.blade.php
Normal file
164
resources/views/seller/component-categories/edit.blade.php
Normal file
@@ -0,0 +1,164 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-edit] size-8"></span>
|
||||
Edit Component Category
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Update category information</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.component-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Categories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.component-categories.update', [$business->slug, $componentCategory->id]) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Name -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Category Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value="{{ old('name', $componentCategory->name) }}"
|
||||
placeholder="e.g., Raw Materials, Packaging, Hardware"
|
||||
required
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
/>
|
||||
@error('name')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Parent Category -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="parent_id">
|
||||
<span class="label-text font-medium">Parent Category</span>
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
class="select select-bordered w-full @error('parent_id') select-error @enderror"
|
||||
>
|
||||
<option value="">None (Root Category)</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ old('parent_id', $componentCategory->parent_id) == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('parent_id')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Leave empty to keep as a top-level category</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sort_order">
|
||||
<span class="label-text font-medium">Sort Order</span>
|
||||
</label>
|
||||
<input
|
||||
id="sort_order"
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value="{{ old('sort_order', $componentCategory->sort_order) }}"
|
||||
min="0"
|
||||
class="input input-bordered w-full @error('sort_order') input-error @enderror"
|
||||
/>
|
||||
@error('sort_order')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Lower numbers appear first</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
placeholder="Optional description of this category"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
>{{ old('description', $componentCategory->description) }}</textarea>
|
||||
@error('description')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Public -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="public"
|
||||
id="public"
|
||||
value="1"
|
||||
{{ old('public', $componentCategory->public) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Public Category</span>
|
||||
<p class="text-xs text-base-content/60">Make this category visible to buyers in the marketplace</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Category Stats -->
|
||||
@if($componentCategory->children->count() > 0 || $componentCategory->components->count() > 0)
|
||||
<div class="md:col-span-2">
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-[lucide--info] size-5"></span>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">Category Information</p>
|
||||
<p>This category has {{ $componentCategory->children->count() }} subcategories and {{ $componentCategory->components->count() }} components.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Update Category
|
||||
</button>
|
||||
<a href="{{ route('seller.business.component-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
144
resources/views/seller/component-categories/import.blade.php
Normal file
144
resources/views/seller/component-categories/import.blade.php
Normal file
@@ -0,0 +1,144 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--file-up] size-8"></span>
|
||||
Import Component Categories
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Upload a CSV file to bulk import categories</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.component-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Categories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Card -->
|
||||
<div class="alert alert-info mb-6">
|
||||
<span class="icon-[lucide--info] size-5"></span>
|
||||
<div>
|
||||
<h3 class="font-bold">CSV Format Instructions</h3>
|
||||
<div class="text-sm mt-2">
|
||||
<p class="mb-2">Your CSV file must include the following columns (in order):</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li><strong>name</strong> - Category name (required)</li>
|
||||
<li><strong>description</strong> - Category description (optional)</li>
|
||||
<li><strong>parent_name</strong> - Parent category name (optional, must exist or be in the same file)</li>
|
||||
<li><strong>public</strong> - Visibility (true/false, default: true)</li>
|
||||
<li><strong>sort_order</strong> - Display order (number, default: 0)</li>
|
||||
</ul>
|
||||
<p class="mt-3"><strong>Note:</strong> Categories with parents will be created after root categories. Make sure parent categories are either already in your database or listed before their children in the CSV.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sample CSV Download -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 class="card-title">
|
||||
<span class="icon-[lucide--file-spreadsheet] size-5"></span>
|
||||
Sample CSV Template
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">Download this sample template to get started</p>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.component-categories.import.sample', $business->slug) }}" class="btn btn-primary btn-sm">
|
||||
<span class="icon-[lucide--download] size-4"></span>
|
||||
Download CSV
|
||||
</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>description</th>
|
||||
<th>parent_name</th>
|
||||
<th>public</th>
|
||||
<th>sort_order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Raw Materials</td>
|
||||
<td>Base ingredients and raw materials</td>
|
||||
<td></td>
|
||||
<td>true</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cannabis Flower</td>
|
||||
<td>Raw cannabis flower</td>
|
||||
<td>Raw Materials</td>
|
||||
<td>true</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Distillate</td>
|
||||
<td>Cannabis distillate</td>
|
||||
<td>Raw Materials</td>
|
||||
<td>true</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Packaging</td>
|
||||
<td>Product packaging materials</td>
|
||||
<td></td>
|
||||
<td>true</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.component-categories.import.process', $business->slug) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="file">
|
||||
<span class="label-text font-medium">CSV File <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="file"
|
||||
name="file"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
required
|
||||
class="file-input file-input-bordered w-full @error('file') file-input-error @enderror"
|
||||
/>
|
||||
@error('file')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Maximum file size: 2MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--upload] size-4"></span>
|
||||
Import Categories
|
||||
</button>
|
||||
<a href="{{ route('seller.business.component-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
449
resources/views/seller/component-categories/index.blade.php
Normal file
449
resources/views/seller/component-categories/index.blade.php
Normal file
@@ -0,0 +1,449 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div x-data="categoriesManager()">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-tree] size-8"></span>
|
||||
Component Categories
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Organize your components into categories</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.component-categories.import', $business->slug) }}" class="btn btn-outline">
|
||||
<span class="icon-[lucide--file-up] size-4.5"></span>
|
||||
Import
|
||||
</a>
|
||||
<a href="{{ route('seller.business.component-categories.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-4.5"></span>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- Search Input -->
|
||||
<div class="lg:col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterCategories()"
|
||||
placeholder="Search categories..."
|
||||
class="input input-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Filter -->
|
||||
<div>
|
||||
<select x-model="visibilityFilter" @change="filterCategories()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Visibility</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Level Filter -->
|
||||
<div>
|
||||
<select x-model="levelFilter" @change="filterCategories()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Levels</option>
|
||||
<option value="0">Root Categories</option>
|
||||
<option value="1">Subcategories</option>
|
||||
<option value="2">Sub-subcategories</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Showing <span class="font-semibold text-base-content" x-text="paginatedCategories.length"></span> of
|
||||
<span class="font-semibold text-base-content" x-text="filteredCategories.length"></span>
|
||||
<template x-if="filteredCategories.length !== allCategories.length">
|
||||
<span class="text-primary">(filtered from <span x-text="allCategories.length"></span> total)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<template x-if="searchQuery || visibilityFilter || levelFilter">
|
||||
<button @click="clearFilters()" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[lucide--x] size-4"></span>
|
||||
Clear Filters
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="selectedCategories.length > 0" x-cloak class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="icon-[lucide--check-square] size-5"></span>
|
||||
<span class="font-medium" x-text="`${selectedCategories.length} selected`"></span>
|
||||
</div>
|
||||
<button @click="bulkDelete()" type="button" class="btn btn-error btn-sm">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Form (hidden) -->
|
||||
<form x-ref="bulkDeleteForm" action="{{ route('seller.business.component-categories.bulk-delete', $business->slug) }}" method="POST" style="display: none;">
|
||||
@csrf
|
||||
<template x-for="categoryId in selectedCategories" :key="categoryId">
|
||||
<input type="hidden" name="category_ids[]" :value="categoryId">
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<!-- Categories Table -->
|
||||
<div class="card bg-base-100 shadow overflow-visible">
|
||||
<div class="card-body p-0 overflow-visible">
|
||||
<template x-if="allCategories.length > 0">
|
||||
<div class="overflow-x-auto overflow-y-visible">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" @change="toggleSelectAll()" :checked="isAllSelected()" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</th>
|
||||
<th @click="sortBy('name')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Name
|
||||
<span x-show="sortColumn === 'name'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Parent Category</th>
|
||||
<th>Description</th>
|
||||
<th>Visibility</th>
|
||||
<th @click="sortBy('sort_order')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Sort Order
|
||||
<span x-show="sortColumn === 'sort_order'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Components</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="category in paginatedCategories" :key="category.id">
|
||||
<tr>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" :value="category.id" @change="toggleCategorySelection(category.id)" :checked="selectedCategories.includes(category.id)" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Indentation for hierarchy -->
|
||||
<template x-if="category.level > 0">
|
||||
<span class="text-base-content/40" x-html="' '.repeat(category.level) + '└─'"></span>
|
||||
</template>
|
||||
<span class="font-semibold" x-text="category.name"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.parent_name">
|
||||
<span class="badge badge-ghost" x-text="category.parent_name"></span>
|
||||
</template>
|
||||
<template x-if="!category.parent_name">
|
||||
<span class="text-base-content/40">Root</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.description">
|
||||
<span class="text-sm text-base-content/70" x-text="category.description.substring(0, 50) + (category.description.length > 50 ? '...' : '')"></span>
|
||||
</template>
|
||||
<template x-if="!category.description">
|
||||
<span class="text-base-content/40">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.public">
|
||||
<span class="badge badge-primary">Public</span>
|
||||
</template>
|
||||
<template x-if="!category.public">
|
||||
<span class="badge badge-ghost">Private</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm" x-text="category.sort_order"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline" x-text="category.components_count"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li>
|
||||
<a :href="`/s/{{ $business->slug }}/component-categories/${category.id}/edit`">
|
||||
<span class="icon-[lucide--edit] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="deleteCategory(category)" class="text-error">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allCategories.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--folder-tree] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No categories found</h3>
|
||||
<p class="text-base-content/60 mb-4">Get started by creating your first product category</p>
|
||||
<a href="{{ route('seller.business.component-categories.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-5"></span>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allCategories.length > 0 && filteredCategories.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--search] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template x-if="filteredCategories.length > perPage">
|
||||
<div class="border-t border-base-300 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<button @click="goToPage(currentPage - 1)" :disabled="currentPage === 1" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-left] size-4"></span>
|
||||
</button>
|
||||
<template x-for="page in visiblePages" :key="page">
|
||||
<button @click="goToPage(page)" :class="{ 'btn-active': page === currentPage }" class="join-item btn btn-sm" x-text="page"></button>
|
||||
</template>
|
||||
<button @click="goToPage(currentPage + 1)" :disabled="currentPage === totalPages" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-right] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<select x-model="perPage" @change="updatePagination()" class="select select-bordered select-sm">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="25">25 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
<option value="100">100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form x-ref="deleteForm" method="POST" style="display: none;">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function categoriesManager() {
|
||||
return {
|
||||
allCategories: @json($categoriesData),
|
||||
filteredCategories: [],
|
||||
paginatedCategories: [],
|
||||
searchQuery: '',
|
||||
visibilityFilter: '',
|
||||
levelFilter: '',
|
||||
sortColumn: 'sort_order',
|
||||
sortDirection: 'asc',
|
||||
selectedCategories: [],
|
||||
currentPage: 1,
|
||||
perPage: 25,
|
||||
totalPages: 1,
|
||||
visiblePages: [],
|
||||
|
||||
init() {
|
||||
this.filteredCategories = [...this.allCategories];
|
||||
this.sortCategories();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
filterCategories() {
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
|
||||
this.filteredCategories = this.allCategories.filter(category => {
|
||||
// Search filter
|
||||
const matchesSearch = !query ||
|
||||
category.name.toLowerCase().includes(query) ||
|
||||
(category.description && category.description.toLowerCase().includes(query)) ||
|
||||
(category.parent_name && category.parent_name.toLowerCase().includes(query));
|
||||
|
||||
// Visibility filter
|
||||
const matchesVisibility = !this.visibilityFilter ||
|
||||
(this.visibilityFilter === 'public' && category.public) ||
|
||||
(this.visibilityFilter === 'private' && !category.public);
|
||||
|
||||
// Level filter
|
||||
const matchesLevel = !this.levelFilter || category.level === parseInt(this.levelFilter);
|
||||
|
||||
return matchesSearch && matchesVisibility && matchesLevel;
|
||||
});
|
||||
|
||||
this.sortCategories();
|
||||
this.currentPage = 1;
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.searchQuery = '';
|
||||
this.visibilityFilter = '';
|
||||
this.levelFilter = '';
|
||||
this.filterCategories();
|
||||
},
|
||||
|
||||
sortBy(column) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.sortCategories();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
sortCategories() {
|
||||
this.filteredCategories.sort((a, b) => {
|
||||
let aVal = a[this.sortColumn];
|
||||
let bVal = b[this.sortColumn];
|
||||
|
||||
if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
updatePagination() {
|
||||
this.totalPages = Math.ceil(this.filteredCategories.length / this.perPage);
|
||||
if (this.currentPage > this.totalPages) {
|
||||
this.currentPage = Math.max(1, this.totalPages);
|
||||
}
|
||||
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = start + this.perPage;
|
||||
this.paginatedCategories = this.filteredCategories.slice(start, end);
|
||||
|
||||
this.updateVisiblePages();
|
||||
},
|
||||
|
||||
updateVisiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
let startPage = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(this.totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage < maxVisible - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
this.visiblePages = pages;
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.updatePagination();
|
||||
}
|
||||
},
|
||||
|
||||
toggleCategorySelection(categoryId) {
|
||||
const index = this.selectedCategories.indexOf(categoryId);
|
||||
if (index > -1) {
|
||||
this.selectedCategories.splice(index, 1);
|
||||
} else {
|
||||
this.selectedCategories.push(categoryId);
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selectedCategories = [];
|
||||
} else {
|
||||
this.selectedCategories = this.paginatedCategories.map(c => c.id);
|
||||
}
|
||||
},
|
||||
|
||||
isAllSelected() {
|
||||
return this.paginatedCategories.length > 0 &&
|
||||
this.paginatedCategories.every(c => this.selectedCategories.includes(c.id));
|
||||
},
|
||||
|
||||
deleteCategory(category) {
|
||||
if (!confirm('Are you sure you want to delete this category?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const businessSlug = '{{ $business->slug }}';
|
||||
const form = this.$refs.deleteForm;
|
||||
form.action = `/s/${businessSlug}/component-categories/${category.id}`;
|
||||
form.submit();
|
||||
},
|
||||
|
||||
bulkDelete() {
|
||||
if (!confirm(`Are you sure you want to delete ${this.selectedCategories.length} category(ies)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.bulkDeleteForm.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@endsection
|
||||
@@ -0,0 +1,466 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div x-data="categoriesManager()">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-tree] size-8"></span>
|
||||
Product Categories
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Organize your products into categories</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.product-categories.import', $business->slug) }}" class="btn btn-outline">
|
||||
<span class="icon-[lucide--file-up] size-4.5"></span>
|
||||
Import
|
||||
</a>
|
||||
<a href="{{ route('seller.business.product-categories.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-4.5"></span>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- Search Input -->
|
||||
<div class="lg:col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterCategories()"
|
||||
placeholder="Search categories..."
|
||||
class="input input-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Filter -->
|
||||
<div>
|
||||
<select x-model="visibilityFilter" @change="filterCategories()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Visibility</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Level Filter -->
|
||||
<div>
|
||||
<select x-model="levelFilter" @change="filterCategories()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Levels</option>
|
||||
<option value="0">Root Categories</option>
|
||||
<option value="1">Subcategories</option>
|
||||
<option value="2">Sub-subcategories</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Showing <span class="font-semibold text-base-content" x-text="paginatedCategories.length"></span> of
|
||||
<span class="font-semibold text-base-content" x-text="filteredCategories.length"></span>
|
||||
<template x-if="filteredCategories.length !== allCategories.length">
|
||||
<span class="text-primary">(filtered from <span x-text="allCategories.length"></span> total)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<template x-if="searchQuery || visibilityFilter || levelFilter">
|
||||
<button @click="clearFilters()" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[lucide--x] size-4"></span>
|
||||
Clear Filters
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="selectedCategories.length > 0" x-cloak class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="icon-[lucide--check-square] size-5"></span>
|
||||
<span class="font-medium" x-text="`${selectedCategories.length} selected`"></span>
|
||||
</div>
|
||||
<button @click="bulkDelete()" type="button" class="btn btn-error btn-sm">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Form (hidden) -->
|
||||
<form x-ref="bulkDeleteForm" action="{{ route('seller.business.product-categories.bulk-delete', $business->slug) }}" method="POST" style="display: none;">
|
||||
@csrf
|
||||
<template x-for="categoryId in selectedCategories" :key="categoryId">
|
||||
<input type="hidden" name="category_ids[]" :value="categoryId">
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<!-- Categories Table -->
|
||||
<div class="card bg-base-100 shadow overflow-visible">
|
||||
<div class="card-body p-0 overflow-visible">
|
||||
<template x-if="allCategories.length > 0">
|
||||
<div class="overflow-x-auto overflow-y-visible">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" @change="toggleSelectAll()" :checked="isAllSelected()" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</th>
|
||||
<th>Image</th>
|
||||
<th @click="sortBy('name')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Name
|
||||
<span x-show="sortColumn === 'name'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Parent Category</th>
|
||||
<th>Description</th>
|
||||
<th>Visibility</th>
|
||||
<th @click="sortBy('sort_order')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Sort Order
|
||||
<span x-show="sortColumn === 'sort_order'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Products</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="category in paginatedCategories" :key="category.id">
|
||||
<tr>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" :value="category.id" @change="toggleCategorySelection(category.id)" :checked="selectedCategories.includes(category.id)" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.image_url">
|
||||
<div class="avatar">
|
||||
<div class="w-12 h-12 rounded">
|
||||
<img :src="category.image_url" :alt="category.name" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!category.image_url">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded w-12">
|
||||
<span class="icon-[lucide--folder] size-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Indentation for hierarchy -->
|
||||
<template x-if="category.level > 0">
|
||||
<span class="text-base-content/40" x-html="' '.repeat(category.level) + '└─'"></span>
|
||||
</template>
|
||||
<span class="font-semibold" x-text="category.name"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.parent_name">
|
||||
<span class="badge badge-ghost" x-text="category.parent_name"></span>
|
||||
</template>
|
||||
<template x-if="!category.parent_name">
|
||||
<span class="text-base-content/40">Root</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.description">
|
||||
<span class="text-sm text-base-content/70" x-text="category.description.substring(0, 50) + (category.description.length > 50 ? '...' : '')"></span>
|
||||
</template>
|
||||
<template x-if="!category.description">
|
||||
<span class="text-base-content/40">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.public">
|
||||
<span class="badge badge-primary">Public</span>
|
||||
</template>
|
||||
<template x-if="!category.public">
|
||||
<span class="badge badge-ghost">Private</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm" x-text="category.sort_order"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline" x-text="category.products_count"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li>
|
||||
<a :href="`/s/{{ $business->slug }}/product-categories/${category.slug}/edit`">
|
||||
<span class="icon-[lucide--edit] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="deleteCategory(category)" class="text-error">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allCategories.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--folder-tree] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No categories found</h3>
|
||||
<p class="text-base-content/60 mb-4">Get started by creating your first product category</p>
|
||||
<a href="{{ route('seller.business.product-categories.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-5"></span>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allCategories.length > 0 && filteredCategories.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--search] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template x-if="filteredCategories.length > perPage">
|
||||
<div class="border-t border-base-300 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<button @click="goToPage(currentPage - 1)" :disabled="currentPage === 1" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-left] size-4"></span>
|
||||
</button>
|
||||
<template x-for="page in visiblePages" :key="page">
|
||||
<button @click="goToPage(page)" :class="{ 'btn-active': page === currentPage }" class="join-item btn btn-sm" x-text="page"></button>
|
||||
</template>
|
||||
<button @click="goToPage(currentPage + 1)" :disabled="currentPage === totalPages" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-right] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<select x-model="perPage" @change="updatePagination()" class="select select-bordered select-sm">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="25">25 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
<option value="100">100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form x-ref="deleteForm" method="POST" style="display: none;">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function categoriesManager() {
|
||||
return {
|
||||
allCategories: @json($categoriesData),
|
||||
filteredCategories: [],
|
||||
paginatedCategories: [],
|
||||
searchQuery: '',
|
||||
visibilityFilter: '',
|
||||
levelFilter: '',
|
||||
sortColumn: 'sort_order',
|
||||
sortDirection: 'asc',
|
||||
selectedCategories: [],
|
||||
currentPage: 1,
|
||||
perPage: 25,
|
||||
totalPages: 1,
|
||||
visiblePages: [],
|
||||
|
||||
init() {
|
||||
this.filteredCategories = [...this.allCategories];
|
||||
this.sortCategories();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
filterCategories() {
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
|
||||
this.filteredCategories = this.allCategories.filter(category => {
|
||||
// Search filter
|
||||
const matchesSearch = !query ||
|
||||
category.name.toLowerCase().includes(query) ||
|
||||
(category.description && category.description.toLowerCase().includes(query)) ||
|
||||
(category.parent_name && category.parent_name.toLowerCase().includes(query));
|
||||
|
||||
// Visibility filter
|
||||
const matchesVisibility = !this.visibilityFilter ||
|
||||
(this.visibilityFilter === 'public' && category.public) ||
|
||||
(this.visibilityFilter === 'private' && !category.public);
|
||||
|
||||
// Level filter
|
||||
const matchesLevel = !this.levelFilter || category.level === parseInt(this.levelFilter);
|
||||
|
||||
return matchesSearch && matchesVisibility && matchesLevel;
|
||||
});
|
||||
|
||||
this.sortCategories();
|
||||
this.currentPage = 1;
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.searchQuery = '';
|
||||
this.visibilityFilter = '';
|
||||
this.levelFilter = '';
|
||||
this.filterCategories();
|
||||
},
|
||||
|
||||
sortBy(column) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.sortCategories();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
sortCategories() {
|
||||
this.filteredCategories.sort((a, b) => {
|
||||
let aVal = a[this.sortColumn];
|
||||
let bVal = b[this.sortColumn];
|
||||
|
||||
if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
updatePagination() {
|
||||
this.totalPages = Math.ceil(this.filteredCategories.length / this.perPage);
|
||||
if (this.currentPage > this.totalPages) {
|
||||
this.currentPage = Math.max(1, this.totalPages);
|
||||
}
|
||||
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = start + this.perPage;
|
||||
this.paginatedCategories = this.filteredCategories.slice(start, end);
|
||||
|
||||
this.updateVisiblePages();
|
||||
},
|
||||
|
||||
updateVisiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
let startPage = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(this.totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage < maxVisible - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
this.visiblePages = pages;
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.updatePagination();
|
||||
}
|
||||
},
|
||||
|
||||
toggleCategorySelection(categoryId) {
|
||||
const index = this.selectedCategories.indexOf(categoryId);
|
||||
if (index > -1) {
|
||||
this.selectedCategories.splice(index, 1);
|
||||
} else {
|
||||
this.selectedCategories.push(categoryId);
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selectedCategories = [];
|
||||
} else {
|
||||
this.selectedCategories = this.paginatedCategories.map(c => c.id);
|
||||
}
|
||||
},
|
||||
|
||||
isAllSelected() {
|
||||
return this.paginatedCategories.length > 0 &&
|
||||
this.paginatedCategories.every(c => this.selectedCategories.includes(c.id));
|
||||
},
|
||||
|
||||
deleteCategory(category) {
|
||||
if (!confirm('Are you sure you want to delete this category?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const businessSlug = '{{ $business->slug }}';
|
||||
const form = this.$refs.deleteForm;
|
||||
form.action = `/s/${businessSlug}/product-categories/${category.slug}`;
|
||||
form.submit();
|
||||
},
|
||||
|
||||
bulkDelete() {
|
||||
if (!confirm(`Are you sure you want to delete ${this.selectedCategories.length} category(ies)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.bulkDeleteForm.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@endsection
|
||||
@@ -0,0 +1,65 @@
|
||||
<tr>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" value="{{ $category->id }}" class="checkbox checkbox-sm category-checkbox" data-category-id="{{ $category->id }}">
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2" style="padding-left: {{ $level * 2 }}rem;">
|
||||
@if($level > 0)
|
||||
<span class="icon-[lucide--corner-down-right] size-4 text-base-content/40"></span>
|
||||
@endif
|
||||
<div class="font-medium">{{ $category->name }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($category->parent)
|
||||
<span class="text-sm text-base-content/70">{{ $category->parent->name }}</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/40">Root Category</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm text-base-content/70 max-w-xs truncate">
|
||||
{{ $category->description ?? '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($category->public)
|
||||
<span class="badge badge-success badge-sm">Public</span>
|
||||
@else
|
||||
<span class="badge badge-ghost badge-sm">Private</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm">{{ $category->sort_order }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-medium">{{ $category->components->count() }}</span>
|
||||
</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.component-categories.edit', [$business->slug, $category->id]) }}">
|
||||
<span class="icon-[lucide--pencil] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form action="{{ route('seller.business.component-categories.destroy', [$business->slug, $category->id]) }}" method="POST" onsubmit="return confirm('Delete this category? This action cannot be undone.')" 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>
|
||||
@@ -67,20 +67,26 @@
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Type <span class="text-error">*</span></span>
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="type" class="select select-bordered" required>
|
||||
<option value="">Select component type...</option>
|
||||
<option value="flower" {{ old('type') === 'flower' ? 'selected' : '' }}>Flower</option>
|
||||
<option value="concentrate" {{ old('type') === 'concentrate' ? 'selected' : '' }}>Concentrate</option>
|
||||
<option value="packaging" {{ old('type') === 'packaging' ? 'selected' : '' }}>Packaging</option>
|
||||
<option value="hardware" {{ old('type') === 'hardware' ? 'selected' : '' }}>Hardware</option>
|
||||
<option value="paper" {{ old('type') === 'paper' ? 'selected' : '' }}>Paper</option>
|
||||
<option value="flavoring" {{ old('type') === 'flavoring' ? 'selected' : '' }}>Flavoring</option>
|
||||
<option value="carrier_oil" {{ old('type') === 'carrier_oil' ? 'selected' : '' }}>Carrier Oil</option>
|
||||
<option value="other" {{ old('type') === 'other' ? 'selected' : '' }}>Other</option>
|
||||
<select name="category_id" class="select select-bordered">
|
||||
<option value="">Select category...</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('type')<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
@if($categories->isEmpty())
|
||||
<a href="{{ route('seller.business.component-categories.create', $business->slug) }}" class="link link-primary">Create categories</a> to organize components
|
||||
@else
|
||||
Optional - helps organize your components
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@error('category_id')<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>@enderror
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
|
||||
@@ -68,20 +68,26 @@
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Type <span class="text-error">*</span></span>
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="type" class="select select-bordered" required>
|
||||
<option value="">Select component type...</option>
|
||||
<option value="flower" {{ old('type', $component->type) === 'flower' ? 'selected' : '' }}>Flower</option>
|
||||
<option value="concentrate" {{ old('type', $component->type) === 'concentrate' ? 'selected' : '' }}>Concentrate</option>
|
||||
<option value="packaging" {{ old('type', $component->type) === 'packaging' ? 'selected' : '' }}>Packaging</option>
|
||||
<option value="hardware" {{ old('type', $component->type) === 'hardware' ? 'selected' : '' }}>Hardware</option>
|
||||
<option value="paper" {{ old('type', $component->type) === 'paper' ? 'selected' : '' }}>Paper</option>
|
||||
<option value="flavoring" {{ old('type', $component->type) === 'flavoring' ? 'selected' : '' }}>Flavoring</option>
|
||||
<option value="carrier_oil" {{ old('type', $component->type) === 'carrier_oil' ? 'selected' : '' }}>Carrier Oil</option>
|
||||
<option value="other" {{ old('type', $component->type) === 'other' ? 'selected' : '' }}>Other</option>
|
||||
<select name="category_id" class="select select-bordered">
|
||||
<option value="">Select category...</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id', $component->category_id) == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('type')<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
@if($categories->isEmpty())
|
||||
<a href="{{ route('seller.business.component-categories.create', $business->slug) }}" class="link link-primary">Create categories</a> to organize components
|
||||
@else
|
||||
Optional - helps organize your components
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@error('category_id')<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>@enderror
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
|
||||
172
resources/views/seller/product-categories/create.blade.php
Normal file
172
resources/views/seller/product-categories/create.blade.php
Normal file
@@ -0,0 +1,172 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-plus] size-8"></span>
|
||||
Create Product Category
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Add a new category to organize your products</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.product-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Categories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.product-categories.store', $business->slug) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Name -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Category Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value="{{ old('name') }}"
|
||||
placeholder="e.g., Flower, Edibles, Concentrates"
|
||||
required
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
/>
|
||||
@error('name')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Parent Category -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="parent_id">
|
||||
<span class="label-text font-medium">Parent Category</span>
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
class="select select-bordered w-full @error('parent_id') select-error @enderror"
|
||||
>
|
||||
<option value="">None (Root Category)</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ old('parent_id') == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('parent_id')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Leave empty to create a top-level category</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sort_order">
|
||||
<span class="label-text font-medium">Sort Order</span>
|
||||
</label>
|
||||
<input
|
||||
id="sort_order"
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value="{{ old('sort_order', 0) }}"
|
||||
min="0"
|
||||
class="input input-bordered w-full @error('sort_order') input-error @enderror"
|
||||
/>
|
||||
@error('sort_order')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Lower numbers appear first</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
placeholder="Optional description of this category"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
>{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Category Image -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="image">
|
||||
<span class="label-text font-medium">Category Image</span>
|
||||
</label>
|
||||
<input
|
||||
id="image"
|
||||
name="image"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp,image/gif"
|
||||
class="file-input file-input-bordered w-full @error('image') file-input-error @enderror"
|
||||
/>
|
||||
@error('image')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Accepted formats: JPEG, PNG, JPG, WEBP, GIF. Maximum size: 2MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="public"
|
||||
id="public"
|
||||
value="1"
|
||||
{{ old('public', true) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Public Category</span>
|
||||
<p class="text-xs text-base-content/60">Make this category visible to buyers in the marketplace</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Create Category
|
||||
</button>
|
||||
<a href="{{ route('seller.business.product-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
211
resources/views/seller/product-categories/edit.blade.php
Normal file
211
resources/views/seller/product-categories/edit.blade.php
Normal file
@@ -0,0 +1,211 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-edit] size-8"></span>
|
||||
Edit Product Category
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Update category information</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.product-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Categories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.product-categories.update', [$business->slug, $productCategory->id]) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Name -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Category Name <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value="{{ old('name', $productCategory->name) }}"
|
||||
placeholder="e.g., Flower, Edibles, Concentrates"
|
||||
required
|
||||
class="input input-bordered w-full @error('name') input-error @enderror"
|
||||
/>
|
||||
@error('name')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Parent Category -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="parent_id">
|
||||
<span class="label-text font-medium">Parent Category</span>
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
class="select select-bordered w-full @error('parent_id') select-error @enderror"
|
||||
>
|
||||
<option value="">None (Root Category)</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ old('parent_id', $productCategory->parent_id) == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('parent_id')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Leave empty to keep as a top-level category</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="sort_order">
|
||||
<span class="label-text font-medium">Sort Order</span>
|
||||
</label>
|
||||
<input
|
||||
id="sort_order"
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value="{{ old('sort_order', $productCategory->sort_order) }}"
|
||||
min="0"
|
||||
class="input input-bordered w-full @error('sort_order') input-error @enderror"
|
||||
/>
|
||||
@error('sort_order')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Lower numbers appear first</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
placeholder="Optional description of this category"
|
||||
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
|
||||
>{{ old('description', $productCategory->description) }}</textarea>
|
||||
@error('description')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Category Image -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="image">
|
||||
<span class="label-text font-medium">Category Image</span>
|
||||
</label>
|
||||
|
||||
@if($productCategory->hasImage())
|
||||
<div class="mb-4 flex items-start gap-4">
|
||||
<div class="avatar">
|
||||
<div class="w-32 rounded">
|
||||
<img src="{{ $productCategory->image_url }}" alt="{{ $productCategory->name }}" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-base-content/70 mb-2">Current image</p>
|
||||
<p class="text-xs text-base-content/50 mb-2 font-mono">{{ $productCategory->image_path }}</p>
|
||||
<label class="label cursor-pointer justify-start gap-3 w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remove_image"
|
||||
id="remove_image"
|
||||
value="1"
|
||||
class="checkbox checkbox-error checkbox-sm"
|
||||
/>
|
||||
<span class="label-text text-sm">Remove current image</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input
|
||||
id="image"
|
||||
name="image"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,image/webp,image/gif"
|
||||
class="file-input file-input-bordered w-full @error('image') file-input-error @enderror"
|
||||
/>
|
||||
@error('image')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Accepted formats: JPEG, PNG, JPG, WEBP, GIF. Maximum size: 2MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="public"
|
||||
id="public"
|
||||
value="1"
|
||||
{{ old('public', $productCategory->public) ? 'checked' : '' }}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text font-medium">Public Category</span>
|
||||
<p class="text-xs text-base-content/60">Make this category visible to buyers in the marketplace</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Category Stats -->
|
||||
@if($productCategory->children->count() > 0 || $productCategory->products->count() > 0)
|
||||
<div class="md:col-span-2">
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-[lucide--info] size-5"></span>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">Category Information</p>
|
||||
<p>This category has {{ $productCategory->children->count() }} subcategories and {{ $productCategory->products->count() }} products.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
Update Category
|
||||
</button>
|
||||
<a href="{{ route('seller.business.product-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
144
resources/views/seller/product-categories/import.blade.php
Normal file
144
resources/views/seller/product-categories/import.blade.php
Normal file
@@ -0,0 +1,144 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--file-up] size-8"></span>
|
||||
Import Product Categories
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Upload a CSV file to bulk import categories</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ route('seller.business.product-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[lucide--arrow-left] size-4"></span>
|
||||
Back to Categories
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Card -->
|
||||
<div class="alert alert-info mb-6">
|
||||
<span class="icon-[lucide--info] size-5"></span>
|
||||
<div>
|
||||
<h3 class="font-bold">CSV Format Instructions</h3>
|
||||
<div class="text-sm mt-2">
|
||||
<p class="mb-2">Your CSV file must include the following columns (in order):</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li><strong>name</strong> - Category name (required)</li>
|
||||
<li><strong>description</strong> - Category description (optional)</li>
|
||||
<li><strong>parent_name</strong> - Parent category name (optional, must exist or be in the same file)</li>
|
||||
<li><strong>public</strong> - Visibility (true/false, default: true)</li>
|
||||
<li><strong>sort_order</strong> - Display order (number, default: 0)</li>
|
||||
</ul>
|
||||
<p class="mt-3"><strong>Note:</strong> Categories with parents will be created after root categories. Make sure parent categories are either already in your database or listed before their children in the CSV.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sample CSV Download -->
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 class="card-title">
|
||||
<span class="icon-[lucide--file-spreadsheet] size-5"></span>
|
||||
Sample CSV Template
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">Download this sample template to get started</p>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.product-categories.import.sample', $business->slug) }}" class="btn btn-primary btn-sm">
|
||||
<span class="icon-[lucide--download] size-4"></span>
|
||||
Download CSV
|
||||
</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>description</th>
|
||||
<th>parent_name</th>
|
||||
<th>public</th>
|
||||
<th>sort_order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Flower</td>
|
||||
<td>Cannabis flower products</td>
|
||||
<td></td>
|
||||
<td>true</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Indica</td>
|
||||
<td>Relaxing strains</td>
|
||||
<td>Flower</td>
|
||||
<td>true</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sativa</td>
|
||||
<td>Energizing strains</td>
|
||||
<td>Flower</td>
|
||||
<td>true</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Edibles</td>
|
||||
<td>Infused food products</td>
|
||||
<td></td>
|
||||
<td>true</td>
|
||||
<td>2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Form -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('seller.business.product-categories.import.process', $business->slug) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="file">
|
||||
<span class="label-text font-medium">CSV File <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="file"
|
||||
name="file"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
required
|
||||
class="file-input file-input-bordered w-full @error('file') file-input-error @enderror"
|
||||
/>
|
||||
@error('file')
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{{ $message }}</span>
|
||||
</div>
|
||||
@enderror
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">Maximum file size: 2MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center gap-4 mt-8">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-[lucide--upload] size-4"></span>
|
||||
Import Categories
|
||||
</button>
|
||||
<a href="{{ route('seller.business.product-categories.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
466
resources/views/seller/product-categories/index.blade.php
Normal file
466
resources/views/seller/product-categories/index.blade.php
Normal file
@@ -0,0 +1,466 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div x-data="categoriesManager()">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--folder-tree] size-8"></span>
|
||||
Product Categories
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Organize your products into categories</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.product-categories.import', $business->slug) }}" class="btn btn-outline">
|
||||
<span class="icon-[lucide--file-up] size-4.5"></span>
|
||||
Import
|
||||
</a>
|
||||
<a href="{{ route('seller.business.product-categories.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-4.5"></span>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- Search Input -->
|
||||
<div class="lg:col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterCategories()"
|
||||
placeholder="Search categories..."
|
||||
class="input input-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Filter -->
|
||||
<div>
|
||||
<select x-model="visibilityFilter" @change="filterCategories()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Visibility</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Level Filter -->
|
||||
<div>
|
||||
<select x-model="levelFilter" @change="filterCategories()" class="select select-bordered select-sm w-full">
|
||||
<option value="">All Levels</option>
|
||||
<option value="0">Root Categories</option>
|
||||
<option value="1">Subcategories</option>
|
||||
<option value="2">Sub-subcategories</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Showing <span class="font-semibold text-base-content" x-text="paginatedCategories.length"></span> of
|
||||
<span class="font-semibold text-base-content" x-text="filteredCategories.length"></span>
|
||||
<template x-if="filteredCategories.length !== allCategories.length">
|
||||
<span class="text-primary">(filtered from <span x-text="allCategories.length"></span> total)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<template x-if="searchQuery || visibilityFilter || levelFilter">
|
||||
<button @click="clearFilters()" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[lucide--x] size-4"></span>
|
||||
Clear Filters
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div x-show="selectedCategories.length > 0" x-cloak class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="icon-[lucide--check-square] size-5"></span>
|
||||
<span class="font-medium" x-text="`${selectedCategories.length} selected`"></span>
|
||||
</div>
|
||||
<button @click="bulkDelete()" type="button" class="btn btn-error btn-sm">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Form (hidden) -->
|
||||
<form x-ref="bulkDeleteForm" action="{{ route('seller.business.product-categories.bulk-delete', $business->slug) }}" method="POST" style="display: none;">
|
||||
@csrf
|
||||
<template x-for="categoryId in selectedCategories" :key="categoryId">
|
||||
<input type="hidden" name="category_ids[]" :value="categoryId">
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<!-- Categories Table -->
|
||||
<div class="card bg-base-100 shadow overflow-visible">
|
||||
<div class="card-body p-0 overflow-visible">
|
||||
<template x-if="allCategories.length > 0">
|
||||
<div class="overflow-x-auto overflow-y-visible">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" @change="toggleSelectAll()" :checked="isAllSelected()" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</th>
|
||||
<th>Image</th>
|
||||
<th @click="sortBy('name')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Name
|
||||
<span x-show="sortColumn === 'name'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Parent Category</th>
|
||||
<th>Description</th>
|
||||
<th>Visibility</th>
|
||||
<th @click="sortBy('sort_order')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Sort Order
|
||||
<span x-show="sortColumn === 'sort_order'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>Products</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="category in paginatedCategories" :key="category.id">
|
||||
<tr>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" :value="category.id" @change="toggleCategorySelection(category.id)" :checked="selectedCategories.includes(category.id)" class="checkbox checkbox-sm">
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.image_url">
|
||||
<div class="avatar">
|
||||
<div class="w-12 h-12 rounded">
|
||||
<img :src="category.image_url" :alt="category.name" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!category.image_url">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded w-12">
|
||||
<span class="icon-[lucide--folder] size-5"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Indentation for hierarchy -->
|
||||
<template x-if="category.level > 0">
|
||||
<span class="text-base-content/40" x-html="' '.repeat(category.level) + '└─'"></span>
|
||||
</template>
|
||||
<span class="font-semibold" x-text="category.name"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.parent_name">
|
||||
<span class="badge badge-ghost" x-text="category.parent_name"></span>
|
||||
</template>
|
||||
<template x-if="!category.parent_name">
|
||||
<span class="text-base-content/40">Root</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.description">
|
||||
<span class="text-sm text-base-content/70" x-text="category.description.substring(0, 50) + (category.description.length > 50 ? '...' : '')"></span>
|
||||
</template>
|
||||
<template x-if="!category.description">
|
||||
<span class="text-base-content/40">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<template x-if="category.public">
|
||||
<span class="badge badge-primary">Public</span>
|
||||
</template>
|
||||
<template x-if="!category.public">
|
||||
<span class="badge badge-ghost">Private</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm" x-text="category.sort_order"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline" x-text="category.products_count"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li>
|
||||
<a :href="`/s/{{ $business->slug }}/product-categories/${category.id}/edit`">
|
||||
<span class="icon-[lucide--edit] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="deleteCategory(category)" class="text-error">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allCategories.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--folder-tree] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No categories found</h3>
|
||||
<p class="text-base-content/60 mb-4">Get started by creating your first product category</p>
|
||||
<a href="{{ route('seller.business.product-categories.create', $business->slug) }}" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-5"></span>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allCategories.length > 0 && filteredCategories.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--search] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template x-if="filteredCategories.length > perPage">
|
||||
<div class="border-t border-base-300 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
</div>
|
||||
<div class="join">
|
||||
<button @click="goToPage(currentPage - 1)" :disabled="currentPage === 1" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-left] size-4"></span>
|
||||
</button>
|
||||
<template x-for="page in visiblePages" :key="page">
|
||||
<button @click="goToPage(page)" :class="{ 'btn-active': page === currentPage }" class="join-item btn btn-sm" x-text="page"></button>
|
||||
</template>
|
||||
<button @click="goToPage(currentPage + 1)" :disabled="currentPage === totalPages" class="join-item btn btn-sm">
|
||||
<span class="icon-[lucide--chevron-right] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<select x-model="perPage" @change="updatePagination()" class="select select-bordered select-sm">
|
||||
<option value="10">10 per page</option>
|
||||
<option value="25">25 per page</option>
|
||||
<option value="50">50 per page</option>
|
||||
<option value="100">100 per page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form x-ref="deleteForm" method="POST" style="display: none;">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function categoriesManager() {
|
||||
return {
|
||||
allCategories: @json($categoriesData),
|
||||
filteredCategories: [],
|
||||
paginatedCategories: [],
|
||||
searchQuery: '',
|
||||
visibilityFilter: '',
|
||||
levelFilter: '',
|
||||
sortColumn: 'sort_order',
|
||||
sortDirection: 'asc',
|
||||
selectedCategories: [],
|
||||
currentPage: 1,
|
||||
perPage: 25,
|
||||
totalPages: 1,
|
||||
visiblePages: [],
|
||||
|
||||
init() {
|
||||
this.filteredCategories = [...this.allCategories];
|
||||
this.sortCategories();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
filterCategories() {
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
|
||||
this.filteredCategories = this.allCategories.filter(category => {
|
||||
// Search filter
|
||||
const matchesSearch = !query ||
|
||||
category.name.toLowerCase().includes(query) ||
|
||||
(category.description && category.description.toLowerCase().includes(query)) ||
|
||||
(category.parent_name && category.parent_name.toLowerCase().includes(query));
|
||||
|
||||
// Visibility filter
|
||||
const matchesVisibility = !this.visibilityFilter ||
|
||||
(this.visibilityFilter === 'public' && category.public) ||
|
||||
(this.visibilityFilter === 'private' && !category.public);
|
||||
|
||||
// Level filter
|
||||
const matchesLevel = !this.levelFilter || category.level === parseInt(this.levelFilter);
|
||||
|
||||
return matchesSearch && matchesVisibility && matchesLevel;
|
||||
});
|
||||
|
||||
this.sortCategories();
|
||||
this.currentPage = 1;
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.searchQuery = '';
|
||||
this.visibilityFilter = '';
|
||||
this.levelFilter = '';
|
||||
this.filterCategories();
|
||||
},
|
||||
|
||||
sortBy(column) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.sortCategories();
|
||||
this.updatePagination();
|
||||
},
|
||||
|
||||
sortCategories() {
|
||||
this.filteredCategories.sort((a, b) => {
|
||||
let aVal = a[this.sortColumn];
|
||||
let bVal = b[this.sortColumn];
|
||||
|
||||
if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
updatePagination() {
|
||||
this.totalPages = Math.ceil(this.filteredCategories.length / this.perPage);
|
||||
if (this.currentPage > this.totalPages) {
|
||||
this.currentPage = Math.max(1, this.totalPages);
|
||||
}
|
||||
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = start + this.perPage;
|
||||
this.paginatedCategories = this.filteredCategories.slice(start, end);
|
||||
|
||||
this.updateVisiblePages();
|
||||
},
|
||||
|
||||
updateVisiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
let startPage = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(this.totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage < maxVisible - 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
this.visiblePages = pages;
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.updatePagination();
|
||||
}
|
||||
},
|
||||
|
||||
toggleCategorySelection(categoryId) {
|
||||
const index = this.selectedCategories.indexOf(categoryId);
|
||||
if (index > -1) {
|
||||
this.selectedCategories.splice(index, 1);
|
||||
} else {
|
||||
this.selectedCategories.push(categoryId);
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selectedCategories = [];
|
||||
} else {
|
||||
this.selectedCategories = this.paginatedCategories.map(c => c.id);
|
||||
}
|
||||
},
|
||||
|
||||
isAllSelected() {
|
||||
return this.paginatedCategories.length > 0 &&
|
||||
this.paginatedCategories.every(c => this.selectedCategories.includes(c.id));
|
||||
},
|
||||
|
||||
deleteCategory(category) {
|
||||
if (!confirm('Are you sure you want to delete this category?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const businessSlug = '{{ $business->slug }}';
|
||||
const form = this.$refs.deleteForm;
|
||||
form.action = `/s/${businessSlug}/product-categories/${category.id}`;
|
||||
form.submit();
|
||||
},
|
||||
|
||||
bulkDelete() {
|
||||
if (!confirm(`Are you sure you want to delete ${this.selectedCategories.length} category(ies)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.bulkDeleteForm.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@endsection
|
||||
@@ -0,0 +1,80 @@
|
||||
<tr>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" value="{{ $category->id }}" class="checkbox checkbox-sm category-checkbox" data-category-id="{{ $category->id }}">
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
@if($category->image_url)
|
||||
<div class="avatar">
|
||||
<div class="w-12 h-12 rounded">
|
||||
<img src="{{ $category->image_url }}" alt="{{ $category->name }}" onerror="this.parentElement.parentElement.innerHTML='<div class=\'avatar placeholder\'><div class=\'w-12 h-12 rounded bg-base-300 text-base-content/40\'><span class=\'icon-[lucide--folder] size-6\'></span></div></div>';" />
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="avatar placeholder">
|
||||
<div class="w-12 h-12 rounded bg-base-300 text-base-content/40">
|
||||
<span class="icon-[lucide--folder] size-6"></span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2" style="padding-left: {{ $level * 2 }}rem;">
|
||||
@if($level > 0)
|
||||
<span class="icon-[lucide--corner-down-right] size-4 text-base-content/40"></span>
|
||||
@endif
|
||||
<div class="font-medium">{{ $category->name }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($category->parent)
|
||||
<span class="text-sm text-base-content/70">{{ $category->parent->name }}</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/40">Root Category</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm text-base-content/70 max-w-xs truncate">
|
||||
{{ $category->description ?? '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($category->public)
|
||||
<span class="badge badge-success badge-sm">Public</span>
|
||||
@else
|
||||
<span class="badge badge-ghost badge-sm">Private</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm">{{ $category->sort_order }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-medium">{{ $category->products->count() }}</span>
|
||||
</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.product-categories.edit', [$business->slug, $category->id]) }}">
|
||||
<span class="icon-[lucide--pencil] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form action="{{ route('seller.business.product-categories.destroy', [$business->slug, $category->id]) }}" method="POST" onsubmit="return confirm('Delete this category? This action cannot be undone.')" 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>
|
||||
@@ -115,12 +115,27 @@
|
||||
<!-- Category -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Category</span>
|
||||
<span class="label-text">Category <span class="text-error">*</span> </span>
|
||||
</label>
|
||||
<input type="text" name="category" value="{{ old('category') }}" class="input input-bordered" placeholder="e.g., Indica, Sativa, Hybrid" />
|
||||
@error('category')
|
||||
<select name="category_id" class="select select-bordered">
|
||||
<option value="">Select Category</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('category_id')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
@if($categories->isEmpty())
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-warning">
|
||||
No categories available.
|
||||
<a href="{{ route('seller.business.product-categories.create', $business->slug) }}" class="link">Create one</a>
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -216,18 +231,22 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Weight Unit</span>
|
||||
</label>
|
||||
<select name="weight_unit" class="select select-bordered">
|
||||
<select name="unit_id" class="select select-bordered">
|
||||
<option value="">Select Unit</option>
|
||||
<option value="g" {{ old('weight_unit') === 'g' ? 'selected' : '' }}>Grams (g)</option>
|
||||
<option value="oz" {{ old('weight_unit') === 'oz' ? 'selected' : '' }}>Ounces (oz)</option>
|
||||
<option value="lb" {{ old('weight_unit') === 'lb' ? 'selected' : '' }}>Pounds (lb)</option>
|
||||
<option value="kg" {{ old('weight_unit') === 'kg' ? 'selected' : '' }}>Kilograms (kg)</option>
|
||||
<option value="ml" {{ old('weight_unit') === 'ml' ? 'selected' : '' }}>Milliliters (ml)</option>
|
||||
<option value="l" {{ old('weight_unit') === 'l' ? 'selected' : '' }}>Liters (l)</option>
|
||||
@foreach($units as $unit)
|
||||
<option value="{{ $unit->id }}" {{ old('unit_id') == $unit->id ? 'selected' : '' }}>{{ $unit->unit }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('weight_unit')
|
||||
@error('unit_id')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
@if($units->count() === 0)
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
<a href="{{ route('seller.business.units.index', $business->slug) }}" class="link link-primary">Add custom units</a> in settings to use weight units
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,10 +211,25 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Category</span>
|
||||
</label>
|
||||
<input type="text" name="category" value="{{ old('category', $product->category) }}" class="input input-bordered" placeholder="e.g., Indica, Sativa, Hybrid" />
|
||||
@error('category')
|
||||
<select name="category_id" class="select select-bordered">
|
||||
<option value="">Select Category</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->path }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('category_id')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
@if($categories->isEmpty())
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-warning">
|
||||
No categories available.
|
||||
<a href="{{ route('seller.business.product-categories.create', $business->slug) }}" class="link">Create one</a>
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -310,18 +325,22 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Weight Unit</span>
|
||||
</label>
|
||||
<select name="weight_unit" class="select select-bordered">
|
||||
<select name="unit_id" class="select select-bordered">
|
||||
<option value="">Select Unit</option>
|
||||
<option value="g" {{ old('weight_unit', $product->weight_unit) === 'g' ? 'selected' : '' }}>Grams (g)</option>
|
||||
<option value="oz" {{ old('weight_unit', $product->weight_unit) === 'oz' ? 'selected' : '' }}>Ounces (oz)</option>
|
||||
<option value="lb" {{ old('weight_unit', $product->weight_unit) === 'lb' ? 'selected' : '' }}>Pounds (lb)</option>
|
||||
<option value="kg" {{ old('weight_unit', $product->weight_unit) === 'kg' ? 'selected' : '' }}>Kilograms (kg)</option>
|
||||
<option value="ml" {{ old('weight_unit', $product->weight_unit) === 'ml' ? 'selected' : '' }}>Milliliters (ml)</option>
|
||||
<option value="l" {{ old('weight_unit', $product->weight_unit) === 'l' ? 'selected' : '' }}>Liters (l)</option>
|
||||
@foreach($units as $unit)
|
||||
<option value="{{ $unit->id }}" {{ old('unit_id', $product->unit_id) == $unit->id ? 'selected' : '' }}>{{ $unit->unit }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('weight_unit')
|
||||
@error('unit_id')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
@if($units->count() === 0)
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
<a href="{{ route('seller.business.units.index', $business->slug) }}" class="link link-primary">Add custom units</a> in settings to use weight units
|
||||
</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
339
resources/views/seller/units/index.blade.php
Normal file
339
resources/views/seller/units/index.blade.php
Normal file
@@ -0,0 +1,339 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div x-data="unitsManager()">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[lucide--ruler] size-8"></span>
|
||||
Units
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60 mt-1">Manage measurement units for your business</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="openCreateModal()" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-4.5"></span>
|
||||
Add Unit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success mb-6">
|
||||
<span class="icon-[lucide--check-circle] size-5"></span>
|
||||
<span>{{ session('success') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-error mb-6">
|
||||
<span class="icon-[lucide--alert-circle] size-5"></span>
|
||||
<span>{{ session('error') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterUnits()"
|
||||
placeholder="Search units..."
|
||||
class="input input-bordered input-sm w-full max-w-md"
|
||||
/>
|
||||
<span class="text-sm text-base-content/60 flex items-center" x-show="filteredUnits.length !== allUnits.length">
|
||||
Showing <span class="font-semibold mx-1" x-text="filteredUnits.length"></span> of <span class="font-semibold mx-1" x-text="allUnits.length"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Units Table -->
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body p-0">
|
||||
<template x-if="allUnits.length > 0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="sortBy('unit')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Unit
|
||||
<span x-show="sortColumn === 'unit'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th @click="sortBy('created_at')" class="cursor-pointer hover:bg-base-200">
|
||||
<div class="flex items-center gap-2">
|
||||
Created At
|
||||
<span x-show="sortColumn === 'created_at'">
|
||||
<span x-show="sortDirection === 'asc'" class="icon-[lucide--arrow-up] size-4"></span>
|
||||
<span x-show="sortDirection === 'desc'" class="icon-[lucide--arrow-down] size-4"></span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="(unit, index) in filteredUnits" :key="unit.id">
|
||||
<tr>
|
||||
<td class="font-medium" x-text="unit.unit"></td>
|
||||
<td x-text="unit.created_at_formatted"></td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end" :class="{ 'dropdown-top': index >= filteredUnits.length - 3 }">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-52 p-2 z-[1000]">
|
||||
<li>
|
||||
<a href="#" @click.prevent="openEditModal(unit)">
|
||||
<span class="icon-[lucide--pencil] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="deleteUnit(unit)" class="text-error">
|
||||
<span class="icon-[lucide--trash-2] size-4"></span>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allUnits.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--ruler] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No units found</h3>
|
||||
<p class="text-base-content/60 mb-4">Get started by creating your first unit</p>
|
||||
<button @click="openCreateModal()" class="btn btn-primary">
|
||||
<span class="icon-[lucide--plus] size-5"></span>
|
||||
Add Unit
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="allUnits.length > 0 && filteredUnits.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<span class="icon-[lucide--search] size-16 text-base-content/20 mx-auto mb-4 block"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your search</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div x-show="showModal"
|
||||
x-cloak
|
||||
class="modal modal-open"
|
||||
@click.self="closeModal()">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4" x-text="modalMode === 'create' ? 'Create Unit' : 'Edit Unit'"></h3>
|
||||
|
||||
<form @submit.prevent="saveUnit()">
|
||||
<div class="form-control">
|
||||
<label class="label" for="unit-input">
|
||||
<span class="label-text font-medium">Unit <span class="text-error">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="unit-input"
|
||||
type="text"
|
||||
x-model="formData.unit"
|
||||
placeholder="e.g., grams, ounces, pieces, liters"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
:class="{ 'input-error': formErrors.unit }"
|
||||
/>
|
||||
<template x-if="formErrors.unit">
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error" x-text="formErrors.unit"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" @click="closeModal()" class="btn btn-ghost" :disabled="isSubmitting">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
|
||||
<span x-show="!isSubmitting">
|
||||
<span class="icon-[lucide--save] size-4"></span>
|
||||
<span x-text="modalMode === 'create' ? 'Create' : 'Update'"></span>
|
||||
</span>
|
||||
<span x-show="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form x-ref="deleteForm" method="POST" style="display: none;">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function unitsManager() {
|
||||
return {
|
||||
allUnits: @json($unitsData),
|
||||
filteredUnits: [],
|
||||
searchQuery: '',
|
||||
sortColumn: 'unit',
|
||||
sortDirection: 'asc',
|
||||
showModal: false,
|
||||
modalMode: 'create',
|
||||
formData: {
|
||||
unit: ''
|
||||
},
|
||||
formErrors: {},
|
||||
editingUnit: null,
|
||||
isSubmitting: false,
|
||||
|
||||
init() {
|
||||
this.filteredUnits = [...this.allUnits];
|
||||
this.sortUnits();
|
||||
},
|
||||
|
||||
filterUnits() {
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
if (query === '') {
|
||||
this.filteredUnits = [...this.allUnits];
|
||||
} else {
|
||||
this.filteredUnits = this.allUnits.filter(unit =>
|
||||
unit.unit.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
this.sortUnits();
|
||||
},
|
||||
|
||||
sortBy(column) {
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.sortUnits();
|
||||
},
|
||||
|
||||
sortUnits() {
|
||||
this.filteredUnits.sort((a, b) => {
|
||||
let aVal = a[this.sortColumn];
|
||||
let bVal = b[this.sortColumn];
|
||||
|
||||
if (this.sortColumn === 'created_at') {
|
||||
aVal = new Date(aVal);
|
||||
bVal = new Date(bVal);
|
||||
} else {
|
||||
aVal = String(aVal).toLowerCase();
|
||||
bVal = String(bVal).toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.modalMode = 'create';
|
||||
this.formData = { unit: '' };
|
||||
this.formErrors = {};
|
||||
this.editingUnit = null;
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(unit) {
|
||||
this.modalMode = 'edit';
|
||||
this.formData = { unit: unit.unit };
|
||||
this.formErrors = {};
|
||||
this.editingUnit = unit;
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.formData = { unit: '' };
|
||||
this.formErrors = {};
|
||||
this.editingUnit = null;
|
||||
},
|
||||
|
||||
async saveUnit() {
|
||||
this.isSubmitting = true;
|
||||
this.formErrors = {};
|
||||
|
||||
const businessSlug = '{{ $business->slug }}';
|
||||
const url = this.modalMode === 'create'
|
||||
? `/s/${businessSlug}/units`
|
||||
: `/s/${businessSlug}/units/${this.editingUnit.id}`;
|
||||
|
||||
const method = this.modalMode === 'create' ? 'POST' : 'PUT';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Reload page to show success message
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Handle validation errors
|
||||
if (data.errors) {
|
||||
this.formErrors = data.errors;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred. Please try again.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteUnit(unit) {
|
||||
if (!confirm('Are you sure you want to delete this unit?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const businessSlug = '{{ $business->slug }}';
|
||||
const form = this.$refs.deleteForm;
|
||||
form.action = `/s/${businessSlug}/units/${unit.id}`;
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@endsection
|
||||
@@ -63,6 +63,10 @@ Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
|
||||
Route::get('/brands/{brand}', [\App\Http\Controllers\MarketplaceController::class, 'showBrand'])->name('brands.show');
|
||||
Route::get('/brands/{brand}/{product}', [\App\Http\Controllers\MarketplaceController::class, 'showProduct'])->name('brands.products.show');
|
||||
|
||||
// Favorites
|
||||
Route::get('/favorites', [\App\Http\Controllers\Buyer\FavoriteController::class, 'index'])->name('favorites.index');
|
||||
Route::post('/favorites/{product}/toggle', [\App\Http\Controllers\Buyer\FavoriteController::class, 'toggle'])->name('favorites.toggle');
|
||||
|
||||
// Buyer Notification Routes
|
||||
Route::get('/notifications', [\App\Http\Controllers\Buyer\NotificationController::class, 'index'])->name('notifications.index');
|
||||
Route::get('/notifications/dropdown', [\App\Http\Controllers\Buyer\NotificationController::class, 'dropdown'])->name('notifications.dropdown');
|
||||
@@ -94,11 +98,6 @@ Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
|
||||
Route::post('/orders/{order}/cancel', [\App\Http\Controllers\Buyer\OrderController::class, 'cancel'])->name('orders.cancel');
|
||||
Route::get('/orders/{order}/manifest/pdf', [\App\Http\Controllers\Buyer\OrderController::class, 'downloadManifestPdf'])->name('orders.manifest.pdf');
|
||||
|
||||
// Favorites and wishlists
|
||||
Route::get('/favorites', [\App\Http\Controllers\FavoriteController::class, 'index'])->name('favorites.index');
|
||||
Route::post('/favorites/add/{product}', [\App\Http\Controllers\FavoriteController::class, 'add'])->name('favorites.add');
|
||||
Route::delete('/favorites/{product}', [\App\Http\Controllers\FavoriteController::class, 'remove'])->name('favorites.remove');
|
||||
|
||||
// Invoices and payments for buyers
|
||||
Route::get('/invoices', [\App\Http\Controllers\Buyer\InvoiceController::class, 'index'])->name('invoices.index');
|
||||
Route::get('/invoices/{invoice}', [\App\Http\Controllers\Buyer\InvoiceController::class, 'show'])->name('invoices.show');
|
||||
|
||||
@@ -204,5 +204,73 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::put('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'update'])->name('update');
|
||||
Route::delete('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Product Categories Management (business-scoped)
|
||||
Route::prefix('product-categories')->name('product-categories.')->group(function () {
|
||||
// List and bulk actions (must come before parameterized routes)
|
||||
Route::get('/', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'index'])->name('index');
|
||||
Route::post('/actions/bulk-delete', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'bulkDelete'])->name('bulk-delete');
|
||||
|
||||
// Creation routes
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'store'])->name('store');
|
||||
|
||||
// Import routes
|
||||
Route::get('/import', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'import'])->name('import');
|
||||
Route::get('/import/sample', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'downloadSample'])->name('import.sample');
|
||||
Route::post('/import', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'processImport'])->name('import.process');
|
||||
|
||||
// Individual category routes (must come last due to wildcard)
|
||||
Route::get('/{productCategory}/edit', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'edit'])->name('edit');
|
||||
Route::put('/{productCategory}', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'update'])->name('update');
|
||||
Route::delete('/{productCategory}', [\App\Http\Controllers\Seller\ProductCategoryController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Component Categories Management (business-scoped)
|
||||
Route::prefix('component-categories')->name('component-categories.')->group(function () {
|
||||
// List and bulk actions (must come before parameterized routes)
|
||||
Route::get('/', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'index'])->name('index');
|
||||
Route::post('/actions/bulk-delete', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'bulkDelete'])->name('bulk-delete');
|
||||
|
||||
// Creation routes
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'store'])->name('store');
|
||||
|
||||
// Import routes
|
||||
Route::get('/import', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'import'])->name('import');
|
||||
Route::get('/import/sample', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'downloadSample'])->name('import.sample');
|
||||
Route::post('/import', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'processImport'])->name('import.process');
|
||||
|
||||
// Individual category routes (must come last due to wildcard)
|
||||
Route::get('/{componentCategory}/edit', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'edit'])->name('edit');
|
||||
Route::put('/{componentCategory}', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'update'])->name('update');
|
||||
Route::delete('/{componentCategory}', [\App\Http\Controllers\Seller\ComponentCategoryController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Units Management (business-scoped)
|
||||
Route::prefix('units')->name('units.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Seller\UnitController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\UnitController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\UnitController::class, 'store'])->name('store');
|
||||
Route::get('/{unit}/edit', [\App\Http\Controllers\Seller\UnitController::class, 'edit'])->name('edit');
|
||||
Route::put('/{unit}', [\App\Http\Controllers\Seller\UnitController::class, 'update'])->name('update');
|
||||
Route::delete('/{unit}', [\App\Http\Controllers\Seller\UnitController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Brands Management (business-scoped)
|
||||
Route::prefix('brands')->name('brands.')->group(function () {
|
||||
// List and bulk actions
|
||||
Route::get('/', [\App\Http\Controllers\Seller\BrandController::class, 'index'])->name('index');
|
||||
Route::post('/actions/bulk-delete', [\App\Http\Controllers\Seller\BrandController::class, 'bulkDelete'])->name('bulk-delete');
|
||||
|
||||
// Creation routes
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\BrandController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\BrandController::class, 'store'])->name('store');
|
||||
|
||||
// Individual brand routes
|
||||
Route::get('/{brand}/edit', [\App\Http\Controllers\Seller\BrandController::class, 'edit'])->name('edit');
|
||||
Route::put('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'update'])->name('update');
|
||||
Route::delete('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
10
sample-categories.csv
Normal file
10
sample-categories.csv
Normal file
@@ -0,0 +1,10 @@
|
||||
name,description,parent_name,public,sort_order
|
||||
Flower,Cannabis flower products,,true,1
|
||||
Indica,Relaxing strains,Flower,true,1
|
||||
Sativa,Energizing strains,Flower,true,2
|
||||
Hybrid,Balanced strains,Flower,true,3
|
||||
Edibles,Infused food products,,true,2
|
||||
Gummies,Chewy candies,Edibles,true,1
|
||||
Chocolates,Infused chocolate,Edibles,true,2
|
||||
Concentrates,Extracted cannabis products,,true,3
|
||||
Vape Cartridges,Pre-filled vape carts,,true,4
|
||||
|
Reference in New Issue
Block a user