Compare commits

...

16 Commits

Author SHA1 Message Date
Yeltsin Batiancila
eb39392d90 feat: Add fallback image URL logic for product categories 2025-10-31 04:17:01 +08:00
Yeltsin Batiancila
05f8e334cb feat: Implement favorites functionality for users
- Created FavoriteController to manage user's favorite products.
- Added Favorite model to handle favorites data.
- Updated Product model to include relationships for favorites.
- Updated User model to manage user's favorites.
- Created migration for favorites table with unique constraints.
- Developed view for displaying user's favorite products with filters and sorting options.
- Integrated favorite toggle functionality in the marketplace view.
- Added favorites link in the sidebar for easy access.
- Updated routes to include favorites functionality.
2025-10-31 01:26:38 +08:00
Yeltsin Batiancila
3ef8bc6986 Refactor crud management with Alpine.js integration
- Introduced a new component for managing product categories using Alpine.js for improved interactivity.
- Added search and filter functionality for categories based on visibility and level.
- Implemented bulk actions for category deletion with confirmation prompts.
- Enhanced the categories table with sorting and pagination features.
- Updated the UI to provide real-time feedback on selected categories and filtering results.
- Removed legacy success/error message handling in favor of a more dynamic approach.
2025-10-31 00:26:25 +08:00
Yeltsin Batiancila
e20fcb0830 feat: Add banner management to brands
- Added `banner_path` field to the Brand model and database migration.
- Implemented methods for handling brand banners in the Brand model.
- Updated the slug generation to ensure uniqueness with soft-deleted brands.
- Created views for creating, editing, and listing brands with banner upload functionality.
- Added routes for brand management, including bulk delete actions.
- Updated seller sidebar to include a link to the brands management page.
2025-10-30 16:05:12 +08:00
Yeltsin Batiancila
08ccaaf568 feat: Remove PR documentation for component categories and units management 2025-10-30 05:14:02 +08:00
Yeltsin Batiancila
f9c0a19027 feat: Implement unit management and integrate with product forms. 2025-10-30 05:11:33 +08:00
Yeltsin Batiancila
96bc76ec7c Merge branch 'develop' of https://code.cannabrands.app/Cannabrands/hub into feature/component-categories 2025-10-30 04:23:52 +08:00
Yeltsin Batiancila
b910aca32d feat: Implement units management for businesses, including CRUD operations and UI integration 2025-10-30 04:09:06 +08:00
Yeltsin Batiancila
6c5a9ce0c1 feat(seller-sidebar): add settings menu with product and component categories links 2025-10-30 03:06:42 +08:00
Yeltsin Batiancila
b0d3377bdd Merge branch 'feature/product-categories' of https://code.cannabrands.app/Cannabrands/hub into feature/component-categories 2025-10-30 01:21:06 +08:00
Yeltsin Batiancila
32acc62b4b Merge branch 'develop' of https://code.cannabrands.app/Cannabrands/hub into feature/product-categories 2025-10-30 00:23:38 +08:00
Yeltsin Batiancila
4cb157a09b feat: Add category selection for components in create and edit forms 2025-10-29 22:55:45 +08:00
Yeltsin Batiancila
2106828813 feat: Add Component Categories management for seller 2025-10-29 22:18:50 +08:00
Yeltsin Batiancila
098d358937 feat: Enhance product creation and editing with category selection 2025-10-29 06:12:45 +08:00
Yeltsin Batiancila
82b5a44a56 feat: Add bulk delete and import functionality for product categories 2025-10-29 05:45:30 +08:00
Yeltsin Batiancila
a679008f23 feat: Implement product categories management for sellers 2025-10-29 03:10:42 +08:00
51 changed files with 7444 additions and 65 deletions

View 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',
]);
}
}
}

View 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);
}
}

View 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());
}
}
}

View File

@@ -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']);

View 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());
}
}
}

View File

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

View 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.');
}
}

View File

@@ -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();
}
});
}
}

View File

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

View File

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

View 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
View 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);
}
}

View File

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

View 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
View 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);
}
}

View File

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

View File

@@ -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');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::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');
});
}
};

View File

@@ -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');
}
};

View 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::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');
});
}
};

View 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');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('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');
}
};

View 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=&quot;60&quot; height=&quot;60&quot; viewBox=&quot;0 0 60 60&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;%3E%3Cg fill=&quot;none&quot; fill-rule=&quot;evenodd&quot;%3E%3Cg fill=&quot;%23ffffff&quot; fill-opacity=&quot;1&quot;%3E%3Cpath d=&quot;M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z&quot;/%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

View File

@@ -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');
}
}
}
}

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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="'&nbsp;&nbsp;'.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

View 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="'&nbsp;&nbsp;'.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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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="'&nbsp;&nbsp;'.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

View File

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

View File

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

View File

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

View 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

View File

@@ -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');

View File

@@ -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
View 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
1 name description parent_name public sort_order
2 Flower Cannabis flower products true 1
3 Indica Relaxing strains Flower true 1
4 Sativa Energizing strains Flower true 2
5 Hybrid Balanced strains Flower true 3
6 Edibles Infused food products true 2
7 Gummies Chewy candies Edibles true 1
8 Chocolates Infused chocolate Edibles true 2
9 Concentrates Extracted cannabis products true 3
10 Vape Cartridges Pre-filled vape carts true 4