Are you sure?
+Delete {{ $itemLabel }}:
+{{ $itemName ?? '' }}
+This action cannot be undone!
+diff --git a/app/Http/Controllers/admin/product/ProductCategoryController.php b/app/Http/Controllers/admin/product/ProductCategoryController.php
index df3d707..b8f1846 100644
--- a/app/Http/Controllers/admin/product/ProductCategoryController.php
+++ b/app/Http/Controllers/admin/product/ProductCategoryController.php
@@ -56,7 +56,7 @@ class ProductCategoryController extends Controller
public function destroy(int $id)
{
$this->productCategoryService->delete($id);
- return response()->json(['success' => true]);
+ return redirect()->route('products.group.index')->with('success', 'Delete Group Success');
}
public function datatable(Request $request)
diff --git a/app/Http/Controllers/admin/product/ProductController.php b/app/Http/Controllers/admin/product/ProductController.php
index 3c05ff1..015fd82 100644
--- a/app/Http/Controllers/admin/product/ProductController.php
+++ b/app/Http/Controllers/admin/product/ProductController.php
@@ -48,8 +48,11 @@ class ProductController extends Controller
// Fetch OS versions with their group for the OS selector component
$osVersions = \App\Models\Os::with('osGroup')->where('status', 1)->get();
+ // Fetch Control Panels for Hosting products
+ $controlPanels = \App\Models\ControlPanel::active()->ordered()->get();
+
if ($step === 1) {
- return view("administrator.pages.product.create", compact('group', 'type', 'title', 'osVersions'));
+ return view("administrator.pages.product.create", compact('group', 'type', 'title', 'osVersions', 'controlPanels'));
}
if (!$request->has(['step', 'type'])) {
@@ -97,7 +100,10 @@ class ProductController extends Controller
$view = "administrator.pages.product.edit";
- $data = compact('product', 'title', 'group', 'type', 'osVersions');
+ // Fetch Control Panels for Hosting products
+ $controlPanels = \App\Models\ControlPanel::active()->ordered()->get();
+
+ $data = compact('product', 'title', 'group', 'type', 'osVersions', 'controlPanels');
return view($view, $data);
}
diff --git a/app/Http/Requests/Product/ProductRequest.php b/app/Http/Requests/Product/ProductRequest.php
index 86b94c3..7692c52 100644
--- a/app/Http/Requests/Product/ProductRequest.php
+++ b/app/Http/Requests/Product/ProductRequest.php
@@ -49,6 +49,9 @@ class ProductRequest extends FormRequest
],
'short_description' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
+ 'thumbnail' => ['nullable', 'string', 'max:255'],
+ 'gallery' => ['nullable', 'array'],
+ 'gallery.*' => ['nullable', 'string', 'max:255'],
'stock' => ['nullable', 'integer', 'min:0'],
'is_unlimited' => ['boolean'],
'cpu' => ['nullable', 'string', 'max:255'],
@@ -64,11 +67,23 @@ class ProductRequest extends FormRequest
'is_active' => ['boolean'],
'sort_order' => ['nullable', 'integer'],
'published_at' => ['nullable', 'date'],
-
- // OS versions (pivot table os_product)
+
+ // OS versions (pivot table os_product) - for VPS
'os_ids' => ['nullable', 'array'],
'os_ids.*' => ['exists:os,id'],
-
+
+ // Control Panels (pivot table product_control_panel) - for Hosting
+ 'control_panel_ids' => ['nullable', 'array'],
+ 'control_panel_ids.*' => ['exists:control_panels,id'],
+ 'control_panel_price_modifiers' => ['nullable', 'array'],
+ 'control_panel_price_modifiers.*' => ['nullable', 'numeric', 'min:0'],
+ 'control_panel_default' => ['nullable', 'exists:control_panels,id'],
+
+ // Images
+ 'thumbnail' => ['nullable', 'string', 'max:255'],
+ 'gallery' => ['nullable', 'array'],
+ 'gallery.*' => ['nullable', 'string', 'max:255'],
+
// Pricing cycles (table product_pricings)
'pricing' => ['nullable', 'array'],
];
@@ -127,7 +142,14 @@ class ProductRequest extends FormRequest
'short_description.max' => 'Mô tả ngắn không được vượt quá 255 ký tự.',
'description.string' => 'Mô tả phải là chuỗi ký tự.',
-
+
+ 'thumbnail.string' => 'Đường dẫn ảnh đại diện phải là chuỗi ký tự.',
+ 'thumbnail.max' => 'Đường dẫn ảnh đại diện không được vượt quá 255 ký tự.',
+
+ 'gallery.array' => 'Thư viện ảnh không hợp lệ.',
+ 'gallery.*.string' => 'Đường dẫn ảnh trong thư viện phải là chuỗi ký tự.',
+ 'gallery.*.max' => 'Đường dẫn ảnh trong thư viện không được vượt quá 255 ký tự.',
+
'stock.integer' => 'Số lượng tồn kho phải là số nguyên.',
'stock.min' => 'Số lượng tồn kho không được âm.',
@@ -168,7 +190,14 @@ class ProductRequest extends FormRequest
'os_ids.array' => 'Danh sách hệ điều hành không hợp lệ.',
'os_ids.*.exists' => 'Hệ điều hành được chọn không tồn tại.',
-
+
+ 'control_panel_ids.array' => 'Danh sách Control Panel không hợp lệ.',
+ 'control_panel_ids.*.exists' => 'Control Panel được chọn không tồn tại.',
+ 'control_panel_price_modifiers.array' => 'Danh sách phụ phí Control Panel không hợp lệ.',
+ 'control_panel_price_modifiers.*.numeric' => 'Phụ phí Control Panel phải là số.',
+ 'control_panel_price_modifiers.*.min' => 'Phụ phí Control Panel không được âm.',
+ 'control_panel_default.exists' => 'Control Panel mặc định không tồn tại.',
+
'pricing.array' => 'Thông tin giá không hợp lệ.',
'pricing.*.price.numeric' => 'Giá phải là số.',
'pricing.*.price.min' => 'Giá không được âm.',
diff --git a/app/Http/Services/ProductService.php b/app/Http/Services/ProductService.php
index c3d547f..ffd218a 100644
--- a/app/Http/Services/ProductService.php
+++ b/app/Http/Services/ProductService.php
@@ -24,11 +24,16 @@ class ProductService
// Create product
$product = $this->repository->create($productData);
- // Sync OS versions if provided
+ // Sync OS versions if provided (for VPS)
if (!empty($data['os_ids'])) {
$product->osVersions()->sync($data['os_ids']);
}
+ // Sync Control Panels if provided (for Hosting)
+ if (!empty($data['control_panel_ids'])) {
+ $this->saveControlPanels($product, $data);
+ }
+
// Save pricing cycles if provided
if (!empty($data['pricing'])) {
$this->savePricings($product, $data['pricing'], $data['currency'] ?? 'USD');
@@ -48,11 +53,16 @@ class ProductService
// Get the product for relationships
$product = $this->repository->find($id);
- // Sync OS versions if provided
+ // Sync OS versions if provided (for VPS)
if (isset($data['os_ids'])) {
$product->osVersions()->sync($data['os_ids']);
}
+ // Sync Control Panels if provided (for Hosting)
+ if (isset($data['control_panel_ids'])) {
+ $this->saveControlPanels($product, $data);
+ }
+
// Save pricing cycles if provided
if (!empty($data['pricing'])) {
$this->savePricings($product, $data['pricing'], $data['currency'] ?? 'USD');
@@ -95,6 +105,26 @@ class ProductService
}
}
+ /**
+ * Save Control Panels for product (for Hosting)
+ */
+ private function saveControlPanels($product, array $data): void
+ {
+ $syncData = [];
+
+ foreach ($data['control_panel_ids'] as $panelId) {
+ $priceModifier = $data['control_panel_price_modifiers'][$panelId] ?? 0;
+ $isDefault = isset($data['control_panel_default']) && $data['control_panel_default'] == $panelId;
+
+ $syncData[$panelId] = [
+ 'price_modifier' => $priceModifier,
+ 'is_default' => $isDefault,
+ ];
+ }
+
+ $product->controlPanels()->sync($syncData);
+ }
+
/**
* Prepare data for create/update
*/
diff --git a/app/Models/ControlPanel.php b/app/Models/ControlPanel.php
new file mode 100644
index 0000000..a4dc380
--- /dev/null
+++ b/app/Models/ControlPanel.php
@@ -0,0 +1,50 @@
+ 'boolean',
+ ];
+
+ /**
+ * Relationship: Control Panel có nhiều Products (Hosting)
+ */
+ public function products()
+ {
+ return $this->belongsToMany(Product::class, 'product_control_panel')
+ ->withPivot(['price_modifier', 'is_default'])
+ ->withTimestamps();
+ }
+
+ /**
+ * Scope: Chỉ lấy active panels
+ */
+ public function scopeActive($query)
+ {
+ return $query->where('is_active', true);
+ }
+
+ /**
+ * Scope: Sắp xếp theo sort_order
+ */
+ public function scopeOrdered($query)
+ {
+ return $query->orderBy('sort_order', 'asc');
+ }
+}
diff --git a/app/Models/Product.php b/app/Models/Product.php
index 16f5f04..ac11732 100644
--- a/app/Models/Product.php
+++ b/app/Models/Product.php
@@ -19,6 +19,8 @@ class Product extends Model
'slug',
'short_description',
'description',
+ 'thumbnail',
+ 'gallery',
'setup_fee',
'trial_days',
'cpu',
@@ -39,6 +41,7 @@ class Product extends Model
'is_active' => 'boolean',
'is_unlimited' => 'boolean',
'published_at' => 'date',
+ 'gallery' => 'array',
];
/**
@@ -58,6 +61,16 @@ class Product extends Model
->withTimestamps();
}
+ /**
+ * Relationship: Product has many Control Panels (for Hosting)
+ */
+ public function controlPanels()
+ {
+ return $this->belongsToMany(ControlPanel::class, 'product_control_panel')
+ ->withPivot(['price_modifier', 'is_default'])
+ ->withTimestamps();
+ }
+
/**
* Relationship: Product has many pricings
*/
diff --git a/database/migrations/2026_01_27_172907_create_products_table.php b/database/migrations/2026_01_27_172907_create_products_table.php
index a77ada3..147d9f9 100644
--- a/database/migrations/2026_01_27_172907_create_products_table.php
+++ b/database/migrations/2026_01_27_172907_create_products_table.php
@@ -20,6 +20,8 @@ return new class extends Migration {
$table->string('slug')->unique();
$table->string('short_description')->nullable();
$table->text('description')->nullable();
+ $table->string('thumbnail')->nullable();
+ $table->json('gallery')->nullable();
// Trial / stock
$table->integer('stock')->nullable();
diff --git a/database/migrations/2026_02_06_100001_create_control_panels_table.php b/database/migrations/2026_02_06_100001_create_control_panels_table.php
new file mode 100644
index 0000000..f169ee9
--- /dev/null
+++ b/database/migrations/2026_02_06_100001_create_control_panels_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('logo')->nullable();
+ $table->text('description')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->integer('sort_order')->default(0);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('control_panels');
+ }
+};
diff --git a/database/migrations/2026_02_06_100002_create_product_control_panel_table.php b/database/migrations/2026_02_06_100002_create_product_control_panel_table.php
new file mode 100644
index 0000000..46c7197
--- /dev/null
+++ b/database/migrations/2026_02_06_100002_create_product_control_panel_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->unsignedBigInteger('product_id');
+ $table->unsignedBigInteger('control_panel_id');
+ $table->decimal('price_modifier', 15, 2)->default(0)->comment('Phụ phí cho control panel này');
+ $table->boolean('is_default')->default(false)->comment('Panel mặc định cho sản phẩm');
+ $table->timestamps();
+
+ $table->foreign('product_id')
+ ->references('id')
+ ->on('products')
+ ->onDelete('cascade');
+
+ $table->foreign('control_panel_id')
+ ->references('id')
+ ->on('control_panels')
+ ->onDelete('cascade');
+
+ $table->unique(['product_id', 'control_panel_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('product_control_panel');
+ }
+};
diff --git a/database/seeders/ControlPanelSeeder.php b/database/seeders/ControlPanelSeeder.php
new file mode 100644
index 0000000..1c7ba95
--- /dev/null
+++ b/database/seeders/ControlPanelSeeder.php
@@ -0,0 +1,82 @@
+ 'Plesk Panel',
+ 'slug' => 'plesk',
+ 'logo' => '/images/control-panels/plesk.png',
+ 'description' => 'Plesk là control panel thương mại hàng đầu với giao diện thân thiện, hỗ trợ cả Windows và Linux.',
+ 'is_active' => true,
+ 'sort_order' => 1,
+ ],
+ [
+ 'name' => 'DirectAdmin',
+ 'slug' => 'directadmin',
+ 'logo' => '/images/control-panels/directadmin.png',
+ 'description' => 'DirectAdmin là control panel nhẹ, nhanh và ổn định, phù hợp cho các server có cấu hình thấp.',
+ 'is_active' => true,
+ 'sort_order' => 2,
+ ],
+ [
+ 'name' => 'aaPanel',
+ 'slug' => 'aapanel',
+ 'logo' => '/images/control-panels/aapanel.png',
+ 'description' => 'aaPanel là control panel miễn phí với giao diện đơn giản, dễ sử dụng, hỗ trợ LNMP/LAMP.',
+ 'is_active' => true,
+ 'sort_order' => 3,
+ ],
+ [
+ 'name' => 'cPanel',
+ 'slug' => 'cpanel',
+ 'logo' => '/images/control-panels/cpanel.png',
+ 'description' => 'cPanel là control panel phổ biến nhất thế giới với đầy đủ tính năng quản lý hosting.',
+ 'is_active' => true,
+ 'sort_order' => 4,
+ ],
+ [
+ 'name' => 'CyberPanel',
+ 'slug' => 'cyberpanel',
+ 'logo' => '/images/control-panels/cyberpanel.png',
+ 'description' => 'CyberPanel là control panel miễn phí sử dụng OpenLiteSpeed, tối ưu hiệu suất website.',
+ 'is_active' => true,
+ 'sort_order' => 5,
+ ],
+ [
+ 'name' => 'HestiaCP',
+ 'slug' => 'hestiacp',
+ 'logo' => '/images/control-panels/hestiacp.png',
+ 'description' => 'HestiaCP là control panel mã nguồn mở, nhẹ và miễn phí, fork từ VestaCP.',
+ 'is_active' => true,
+ 'sort_order' => 6,
+ ],
+ [
+ 'name' => 'Không có Panel',
+ 'slug' => 'no-panel',
+ 'logo' => null,
+ 'description' => 'Không cài đặt control panel, quản lý server thủ công qua SSH.',
+ 'is_active' => true,
+ 'sort_order' => 99,
+ ],
+ ];
+
+ foreach ($panels as $panel) {
+ ControlPanel::updateOrCreate(
+ ['slug' => $panel['slug']],
+ $panel
+ );
+ }
+ }
+}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index eca5c49..87b7869 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -30,5 +30,6 @@ class DatabaseSeeder extends Seeder
$this->call(TemplateSeeder::class);
$this->call(Setting::class);
$this->call(MenuSeeder::class);
+ $this->call(ControlPanelSeeder::class);
}
}
diff --git a/resources/views/administrator/pages/package/version/datatable/modal.blade.php b/resources/views/administrator/pages/package/version/datatable/modal.blade.php
index 49355e3..532e016 100644
--- a/resources/views/administrator/pages/package/version/datatable/modal.blade.php
+++ b/resources/views/administrator/pages/package/version/datatable/modal.blade.php
@@ -21,11 +21,13 @@
@@ -34,10 +36,10 @@
@push('custom-scripts')
-@endpush
\ No newline at end of file
+@endpush
diff --git a/resources/views/administrator/pages/product/categories/datatable/action.blade.php b/resources/views/administrator/pages/product/categories/datatable/action.blade.php
index f7d4e22..270a9ad 100644
--- a/resources/views/administrator/pages/product/categories/datatable/action.blade.php
+++ b/resources/views/administrator/pages/product/categories/datatable/action.blade.php
@@ -1,2 +1,13 @@
-