feat: Enhance product support in Cart and OrderItem models

- Added new fields 'billing_cycle' and 'product_pricing_id' to Cart and OrderItem models.
- Updated product retrieval logic to support both new and legacy systems.
- Introduced relationships for Product and ProductPricings in Cart and OrderItem models.
- Created migration to add new fields to the carts and order_item tables.
- Updated frontend service index to handle new pricing structure and billing cycles.
- Improved UI components for better user experience in product selection and pricing display.
- Refactored action buttons in various views for consistency and improved styling.
This commit is contained in:
pbao9
2026-02-09 08:04:56 +07:00
parent 4d4bf020d4
commit 057353a2e1
15 changed files with 590 additions and 250 deletions
@@ -14,7 +14,10 @@ use App\Models\InvoiceItem;
use App\Models\ModuleHosting;
use App\Models\ModuleVmware;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Os;
use App\Models\Product;
use App\Models\ProductPricings;
use App\Models\VPS;
use Exception;
use Illuminate\Http\Request;
@@ -36,36 +39,85 @@ class CartController extends Controller
public function addCart(Request $request)
{
$userId = customer()?->id;
$months = $request->input('months');
$productId = $request->product_id;
$typeId = $request->type_id;
if (!$userId) {
return back()->with('error', 'Bạn cần đăng nhập để thêm sản phẩm vào giỏ hàng.');
}
// Validate input data
if (!$productId || !$typeId || !$months) {
return back()->with('error', 'Dữ liệu không hợp lệ.');
// Get request data
$productId = $request->product_id;
$typeId = $request->type_id; // Can be null for new Product system
$months = $request->input('months');
$billingCycle = $request->input('billing_cycle'); // New: billing cycle
$productPrice = $request->input('product_price'); // Price from form
// Validate basic input
if (!$productId) {
return back()->with('error', 'Sản phẩm không hợp lệ.');
}
// Use updateOrCreate instead of delete + create for better performance
try {
CartModel::updateOrCreate(
['customer_id' => $userId], // Only one cart item per user
[
'product_id' => $productId,
'type_product' => $typeId,
'months' => $months,
]
);
// Check if this is new Product system or legacy (Hosting/VPS)
$product = Product::find($productId);
return redirect()->route('cart.index')->with('success', 'Sản phẩm đã được thêm vào giỏ hàng!');
if ($product) {
// NEW FLOW: Using Product table
$pricingId = null;
$finalPrice = $productPrice;
// Find the pricing based on billing_cycle
if ($billingCycle) {
$pricing = ProductPricings::where('product_id', $productId)
->where('billing_cycle', $billingCycle)
->where('is_active', 1)
->first();
if ($pricing) {
$pricingId = $pricing->id;
$finalPrice = $pricing->price_sale ?: $pricing->price;
}
}
// Add to cart
CartModel::updateOrCreate(
['customer_id' => $userId],
[
'product_id' => $productId,
'type_product' => null, // New system doesn't use type_product
'months' => $months,
'billing_cycle' => $billingCycle,
'product_pricing_id' => $pricingId,
'price' => $finalPrice,
'quantity' => 1,
]
);
return redirect()->route('cart.index')->with('success', 'Sản phẩm đã được thêm vào giỏ hàng!');
} else {
// LEGACY FLOW: Using old Hosting/VPS system
if (!$typeId || !$months) {
return back()->with('error', 'Dữ liệu không hợp lệ.');
}
CartModel::updateOrCreate(
['customer_id' => $userId],
[
'product_id' => $productId,
'type_product' => $typeId,
'months' => $months,
'price' => $productPrice,
]
);
return redirect()->route('cart.index')->with('success', 'Sản phẩm đã được thêm vào giỏ hàng!');
}
} catch (\Exception $e) {
Log::error('Cart add error: ' . $e->getMessage(), [
'user_id' => $userId,
'product_id' => $productId,
'type_id' => $typeId
'type_id' => $typeId,
'billing_cycle' => $billingCycle ?? null,
'trace' => $e->getTraceAsString()
]);
return back()->with('error', 'Có lỗi xảy ra khi thêm sản phẩm vào giỏ hàng.');
}
@@ -11,6 +11,7 @@ use App\Models\Hosting;
use App\Models\Group;
use App\Models\Type;
use App\Models\VPS;
use App\Models\ProductCategories;
use App\Repositories\Addon\AddonRepositoryInterface;
use App\Repositories\Domain\DomainRepositoryInterface;
use App\Repositories\Group\GroupRepositoryInterface;
@@ -18,7 +19,6 @@ use App\Repositories\Hosting\HostingRepositoryInterface;
use App\Repositories\Type\TypeRepositoryInterface;
use App\Repositories\VPS\VPSRepositoryInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ShopController extends Controller
{
@@ -34,24 +34,22 @@ class ShopController extends Controller
}
/**
* Display the shop index page with optimized queries
* Display the shop index page
*/
public function index(Request $request)
{
$title = "Dịch vụ của chúng tôi";
// Cache types with groups for better performance
$type = Cache::remember('shop_types_with_groups', 3600, function () {
return Type::with(['group' => function ($query) {
$query->where('status', 1); // Only active groups
}])
// Get types with groups
$type = Type::with(['group' => function ($query) {
$query->where('status', 1); // Only active groups
}])
->whereNotIn('id', [1, 2])
->where('status', 1) // Only active types
->latest('id')
->get();
});
// Handle product filtering with optimized queries
// Handle product filtering
if ($request->type && $request->group) {
$product = $this->getProductsByTypeAndGroup($request->type, $request->group);
$group = $this->getGroupsByType($request->type);
@@ -84,76 +82,67 @@ class ShopController extends Controller
}
/**
* Display products by group slug with pagination and optimization
* Display products by category slug (ProductCategories)
*/
public function show(Request $request, string $slug)
{
// Cache group data for better performance
$page = (int) $request->get('page', 1);
$cacheKey = "group_products_{$slug}_page_{$page}";
$data = Cache::remember($cacheKey, 1800, function () use ($slug, $page) {
// Find group with optimized query
$group = Group::with(['type'])
->where('slug', $slug)
->where('status', 1) // Only active groups
->first();
// Find category by slug
$category = ProductCategories::with(['parent', 'children'])
->where('slug', $slug)
->where('is_active', 1)
->first();
if (!$group) {
return null;
}
// Get products with pagination based on group type
$products = $this->getPaginatedProducts($group, $page);
return [
'group' => $group,
'products' => $products,
];
});
if (!$data) {
abort(404, 'Nhóm sản phẩm không tồn tại');
if (!$category) {
abort(404, 'Danh mục sản phẩm không tồn tại');
}
// Get products with pagination and pricing
$products = $category->products()
->with([
'pricings' => function ($query) {
$query->where('is_active', 1)->orderBy('price');
},
'osVersions.osGroup',
'controlPanels'
])
->where('is_active', 1)
->orderBy('created_at', 'desc')
->paginate(12);
return view('frontend.service.index', [
'group' => $data['group'],
'products' => $data['products'],
'breadcrumbs' => $this->crums->add(__('Dashboard'))->add('Sản phẩm thuộc ' . $data['group']->name),
'category' => $category,
'products' => $products,
'breadcrumbs' => $this->crums->add(__('Dashboard'))->add('Sản phẩm thuộc ' . $category->name),
]);
}
/**
* Get products by type and group with optimization
* Get products by type and group
*/
private function getProductsByTypeAndGroup(int $typeId, int $groupId)
{
return Cache::remember("products_type_{$typeId}_group_{$groupId}", 1800, function () use ($typeId, $groupId) {
return match ($typeId) {
3 => Hosting::where('group_id', $groupId)
->where('status', 1)
->with(['group', 'type'])
->get(),
4 => VPS::where('group_id', $groupId)
->where('status', 1)
->with(['group', 'type', 'operatingSystems.osGroup'])
->get(),
default => collect()
};
});
return match ($typeId) {
3 => Hosting::where('group_id', $groupId)
->where('status', 1)
->with(['group', 'type'])
->get(),
4 => VPS::where('group_id', $groupId)
->where('status', 1)
->with(['group', 'type', 'operatingSystems.osGroup'])
->get(),
default => collect()
};
}
/**
* Get groups by type with optimization
* Get groups by type
*/
private function getGroupsByType(int $typeId)
{
return Cache::remember("groups_type_{$typeId}", 3600, function () use ($typeId) {
return Group::with('type')
->where('type_id', $typeId)
->where('status', 1)
->get();
});
return Group::with('type')
->where('type_id', $typeId)
->where('status', 1)
->get();
}
/**
@@ -171,34 +160,4 @@ class ShopController extends Controller
default => null
};
}
/**
* Get paginated products based on group type
*/
private function getPaginatedProducts(Group $group, int $page = 1, int $perPage = 12)
{
$query = null;
// Determine which model to query based on group type
switch ($group->type_id) {
case 3: // Hosting
$query = Hosting::where('group_id', $group->id)
->where('status', 1)
->with(['group', 'type'])
->orderBy('created_at', 'desc');
break;
case 4: // VPS
$query = VPS::where('group_id', $group->id)
->where('status', 1)
->with(['group', 'type'])
->orderBy('created_at', 'desc');
break;
default:
return collect()->paginate($perPage);
}
return $query->paginate($perPage, ['*'], 'page', $page);
}
}
+38
View File
@@ -16,14 +16,52 @@ class Cart extends Model
'price',
'quantity',
'months',
'billing_cycle', // Add new field for billing cycle
'product_pricing_id', // Add new field to link to ProductPricings
];
/**
* Get product - support both new and legacy system
*/
public function product()
{
// New flow: Check if product exists in Product table first
if ($this->product_id) {
$product = Product::find($this->product_id);
if ($product) {
return $product;
}
}
// Legacy flow: fallback to old system based on type_product
return match ($this->type_product) {
3 => $this->belongsTo(Hosting::class, 'product_id'),
4 => $this->belongsTo(VPS::class, 'product_id'),
default => null,
};
}
/**
* Relationship: Cart belongs to Product (new system)
*/
public function productNew()
{
return $this->belongsTo(Product::class, 'product_id');
}
/**
* Relationship: Cart belongs to Customer
*/
public function customer()
{
return $this->belongsTo(Customer::class, 'customer_id');
}
/**
* Relationship: Cart has pricing info
*/
public function pricing()
{
return $this->belongsTo(ProductPricings::class, 'product_pricing_id');
}
}
+25
View File
@@ -25,6 +25,15 @@ class OrderItem extends Model
public function getProductAttribute()
{
// New flow: Check if product exists in Product table first
if ($this->product_id) {
$product = Product::find($this->product_id);
if ($product) {
return $product;
}
}
// Legacy flow: fallback to old system
return match ($this->product_type) {
3 => Hosting::find($this->product_id),
4 => Vps::find($this->product_id),
@@ -32,6 +41,22 @@ class OrderItem extends Model
};
}
/**
* Relationship: OrderItem belongs to Product (new system)
*/
public function product()
{
return $this->belongsTo(Product::class, 'product_id');
}
/**
* Relationship: OrderItem has pricing info
*/
public function pricing()
{
return $this->belongsTo(ProductPricings::class, 'product_pricing_id');
}
public function os()
+20
View File
@@ -39,4 +39,24 @@ class Os extends Model
->withTimestamps()
->withPivot('product_type');
}
/**
* Many-to-many relationship với Products (new table: os_product)
*/
public function products()
{
return $this->belongsToMany(\App\Models\Product::class, 'os_product', 'os_id', 'product_id')
->withTimestamps();
}
/**
* Get display name with OS Group
*/
public function getNameAttribute()
{
if ($this->osGroup) {
return $this->osGroup->name . ' ' . $this->version;
}
return $this->version;
}
}
+43
View File
@@ -79,6 +79,22 @@ class Product extends Model
return $this->hasMany(ProductPricings::class, 'product_id');
}
/**
* Relationship: Product has many order items
*/
public function orderItems()
{
return $this->hasMany(OrderItem::class, 'product_id');
}
/**
* Relationship: Product has many orders through order items
*/
public function orders()
{
return $this->hasManyThrough(Order::class, OrderItem::class, 'product_id', 'id', 'id', 'order_id');
}
/**
* Legacy: Many-to-many categories (if still needed)
*/
@@ -86,4 +102,31 @@ class Product extends Model
{
return $this->belongsToMany(ProductCategories::class, 'product_product_category', 'product_id', 'product_category_id');
}
/**
* Get monthly pricing (or first available pricing)
*/
public function getMonthlyPricingAttribute()
{
return $this->pricings()
->where('is_active', 1)
->where('billing_cycle', 'monthly')
->first()
?? $this->pricings()->where('is_active', 1)->orderBy('price')->first();
}
/**
* Get display price (sale price if available, otherwise regular price)
*/
public function getPriceAttribute()
{
$pricing = $this->monthlyPricing;
return $pricing ? $pricing->price : 0;
}
public function getPriceSaleAttribute()
{
$pricing = $this->monthlyPricing;
return $pricing ? $pricing->price_sale : null;
}
}
+3 -1
View File
@@ -10,6 +10,8 @@ class ProductCategories extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'product_categories';
protected $fillable = [
'name',
'slug',
@@ -30,6 +32,6 @@ class ProductCategories extends Model
public function products()
{
return $this->belongsToMany(Product::class, 'product_product_category', 'product_category_id', 'product_id');
return $this->hasMany(Product::class, 'product_categories_id');
}
}
@@ -0,0 +1,60 @@
<?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
{
// Add support for new Product system to carts table
Schema::table('carts', function (Blueprint $table) {
if (!Schema::hasColumn('carts', 'billing_cycle')) {
$table->string('billing_cycle', 50)->nullable()->after('months')->comment('Billing cycle: monthly, semi_annually, annually, etc.');
}
if (!Schema::hasColumn('carts', 'product_pricing_id')) {
$table->unsignedBigInteger('product_pricing_id')->nullable()->after('billing_cycle')->comment('Link to product_pricings table');
$table->index('product_pricing_id');
}
// Make type_product nullable for new Product system
$table->integer('type_product')->nullable()->change();
});
// Add support for new Product system to order__order_item table
Schema::table('order__order_item', function (Blueprint $table) {
if (!Schema::hasColumn('order__order_item', 'billing_cycle')) {
$table->string('billing_cycle', 50)->nullable()->after('months')->comment('Billing cycle: monthly, semi_annually, annually, etc.');
}
if (!Schema::hasColumn('order__order_item', 'product_pricing_id')) {
$table->unsignedBigInteger('product_pricing_id')->nullable()->after('billing_cycle')->comment('Link to product_pricings table');
$table->index('product_pricing_id');
}
// Make product_type nullable for new Product system
if (Schema::hasColumn('order__order_item', 'product_type')) {
$table->integer('product_type')->nullable()->change();
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('carts', function (Blueprint $table) {
$table->dropColumn(['billing_cycle', 'product_pricing_id']);
$table->dropIndex(['product_pricing_id']);
});
Schema::table('order__order_item', function (Blueprint $table) {
$table->dropColumn(['billing_cycle', 'product_pricing_id']);
$table->dropIndex(['product_pricing_id']);
});
}
};
+26 -17
View File
@@ -27,15 +27,22 @@ $(document).ready(function () {
$(document).on("click", ".dropdown-option", function (e) {
e.stopPropagation();
const $option = $(this);
const $dropdown = $option.closest(".custom-dropdown");
const $selected = $dropdown.find(".dropdown-selected");
const $options = $dropdown.find(".dropdown-options");
const $selectedText = $selected.find(".selected-text");
// Ignore disabled options
if ($option.hasClass('disabled')) {
return;
}
const $dropdown = $option.closest('.custom-dropdown');
const $selected = $dropdown.find('.dropdown-selected');
const $options = $dropdown.find('.dropdown-options');
const $selectedText = $selected.find('.selected-text');
// Get data from selected option
const value = $option.data("value");
const months = $option.data("months");
const price = $option.data("price");
const value = $option.data('value');
const months = $option.data('months');
const price = $option.data('price');
const cycle = $option.data('cycle'); // Get billing cycle
// Get text from the first span element to avoid getting badge text
const text = $option.find('span').first().text();
@@ -43,19 +50,21 @@ $(document).ready(function () {
$selectedText.text(text);
// Update hidden inputs
$dropdown.closest("form").find(".hiddenMonths").val(months);
$dropdown.closest("form").find(".hiddenTotalPrice").val(price);
const $form = $dropdown.closest('form');
$form.find('.hiddenMonths').val(months);
$form.find('.hiddenProductPrice').val(price); // Update product price
$form.find('.hiddenBillingCycle').val(cycle); // Update billing cycle
// Update selected state
$options.find(".dropdown-option").removeClass("selected");
$option.addClass("selected");
$options.find('.dropdown-option').removeClass('selected');
$option.addClass('selected');
// Close dropdown
$options.removeClass("show");
$selected.removeClass("active");
$options.removeClass('show');
$selected.removeClass('active');
// Trigger custom event for other components
$dropdown.trigger("dropdown:changed", {
$dropdown.trigger('dropdown:changed', {
value: value,
months: months,
price: price,
@@ -66,19 +75,19 @@ $(document).ready(function () {
const $productContainer = $dropdown.closest('[data-product-id]');
const basePrice = parseFloat($productContainer.data('base-price'));
const selectedMonths = parseInt(months);
// Calculate prices
const originalPrice = basePrice * selectedMonths;
const savings = selectedMonths >= 12 ? Math.round(originalPrice * 0.1) : 0;
const finalPrice = originalPrice - savings;
// Update display
$productContainer.find('.total-price-display').text('₫' + finalPrice.toLocaleString());
$productContainer.find('.months-display').text(selectedMonths + ' tháng');
$productContainer.find('.price-per-month').text('₫' + basePrice.toLocaleString() + '/tháng');
$productContainer.find('.original-price').text('₫' + originalPrice.toLocaleString());
$productContainer.find('.savings-amount').text('₫' + savings.toLocaleString());
// Show/hide savings info
if (savings > 0) {
$productContainer.find('.savings-info').show();
@@ -1,12 +1,12 @@
<x-button-crm type="button" variant="primary" size="sm" route="module.vcenter.create" icon="plus-circle">Add
<x-button-crm type="button" variant="outline-primary" size="sm" route="module.vcenter.create" icon="plus-circle">Add
Server</x-button-crm>
<form action="{{ route('module.vcenter.import') }}" class="d-lg-inline" enctype="multipart/form-data" method="post">
@csrf
<input type="file" name="file" class="d-none file-selector"
accept=".csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onchange="form.submit()" />
<x-button-crm type="button" variant="success" size="sm" route="module.vcenter.import" icon="plus-circle">Import
<x-button-crm type="button" variant="outline-info" size="sm" route="module.vcenter.import" icon="plus-circle">Import
From
Excel</x-button-crm>
</form>
<x-button-crm type="button" variant="secondary" size="sm" url="file/vCenter.xlsx" icon="download">File Import
<x-button-crm type="button" variant="outline-secondary" size="sm" url="file/vCenter.xlsx" icon="download">File Import
Demo</x-button-crm>
@@ -44,7 +44,7 @@
@section('content')
@if (auth()->guard('admins')->user()->can('manager module hosting'))
<div class="card card-primary card-outline">
<div class="card-header">
<div class="card-header d-flex justify-content-end gap-2">
@include('administrator.pages.block.ModuleHosting')
</div>
<div class="card-body">
@@ -1,2 +1,7 @@
<x-button-crm type="button" variant="primary" size="sm" route="module.vcenter.edit" :params="['id' => $row->id]" icon="pencil">Edit</x-button-crm>
<x-button-crm type="button" variant="danger" size="sm" route="module.vcenter.destroy" :params="['id' => $row->id]" icon="trash">Delete</x-button-crm>
<div class="d-flex gap-2">
<x-button-crm type="button" variant="primary" size="sm" route="module.vcenter.edit" :params="['id' => $row->id]"
icon="pencil">Edit</x-button-crm>
<x-button-crm type="button" variant="danger" size="sm" route="module.vcenter.destroy" :params="['id' => $row->id]"
icon="trash">Delete</x-button-crm>
</div>
@@ -1,2 +1,6 @@
<x-button-crm type="button" variant="primary" size="sm" route="products.edit" :params="['id' => $row->id]" icon="pencil">Chỉnh sửa</x-button-crm>
<x-button-crm type="button" variant="danger" size="sm" route="products.destroy" :params="['id' => $row->id]" icon="trash">Xóa</x-button-crm>
<div class="d-flex gap-2">
<x-button-crm type="button" variant="outline-primary" size="sm" route="products.edit" :params="['id' => $row->id]"
icon="pencil">Chỉnh sửa</x-button-crm>
<x-button-crm type="button" variant="outline-danger" size="sm" route="products.destroy" :params="['id' => $row->id]"
icon="trash">Xóa</x-button-crm>
</div>
+239 -116
View File
@@ -1,42 +1,65 @@
@extends('frontend.layouts.master')
@section('content')
@if (!empty($group))
@if (!empty($category))
<!-- Products Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-8" x-data>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2 xl:grid-cols-4" x-data>
@forelse ($products as $index => $item)
<form action="{{ route('cart.add') }}" method="post">
@csrf
<input name="product_id" value="{{ $item->id }}" type="hidden" />
<input name="type_id" value="{{ $item->type_id }}" type="hidden" />
<input name="product_price" value="{{ $item->price_sale ?: $item->price }}" type="hidden" />
{{-- Remove type_id for new Product system --}}
@php
$defaultPricingForCart =
$item->pricings->firstWhere('billing_cycle', 'semi_annually') ?? $item->pricings->first();
$cartPrice = $defaultPricingForCart
? floatval($defaultPricingForCart->price_sale ?: $defaultPricingForCart->price)
: 0;
@endphp
<input name="product_price" value="{{ $cartPrice }}" type="hidden" class="hiddenProductPrice" />
<div class="group relative bg-white rounded-md hover:shadow-2xl transition-all duration-500 transform overflow-hidden border border-gray-100"
<div class="group relative transform overflow-hidden rounded-md border border-gray-100 bg-white transition-all duration-500 hover:shadow-2xl"
x-init="$el.style.opacity = 0;
setTimeout(() => { $el.style.opacity = 1 }, {{ (int) $index * 150 }})" style="opacity: 0">
<!-- Header with gradient -->
<div class="p-2 border-b border-dashed border-gray-200">
<h3 class="text-xl text-primary font-bold mb-2 text-center">
<div class="border-b border-dashed border-gray-200 p-2">
<h3 class="text-primary mb-2 text-center text-xl font-bold">
{{ $item->name }}
</h3>
</div>
@php
$basePrice = floatval($item->price_sale ?: $item->price);
// Get pricing for different billing cycles
// Note: If billing_cycle is 'onetime', treat it as monthly
$monthlyPricing =
$item->pricings->firstWhere('billing_cycle', 'monthly') ??
$item->pricings->firstWhere('billing_cycle', 'onetime');
$semiAnnuallyPricing = $item->pricings->firstWhere('billing_cycle', 'semi_annually');
$annuallyPricing = $item->pricings->firstWhere('billing_cycle', 'annually');
$bienniallyPricing = $item->pricings->firstWhere('billing_cycle', 'biennially');
$trienniallyPricing = $item->pricings->firstWhere('billing_cycle', 'triennially');
// Use monthly or first available pricing for display
$displayPricing = $monthlyPricing ?? $item->pricings->first();
$basePrice = $displayPricing
? floatval($displayPricing->price_sale ?: $displayPricing->price)
: 0;
@endphp
<div class="p-6" data-product-id="{{ $item->id }}" data-base-price="{{ $basePrice }}">
<!-- Price Display -->
<div class="text-center">
<div
class="inline-flex items-center justify-center gap-2 bg-orange-50 rounded-md w-full mb-3 p-2">
<span class="text-sm font-bold text-primary">
≈ {{ format_price($item->price_sale ?: $item->price) }}
class="mb-3 inline-flex w-full items-center justify-center gap-2 rounded-md bg-orange-50 p-2">
<span class="text-primary text-sm font-bold">
≈
{{ $displayPricing ? format_price($displayPricing->price_sale ?: $displayPricing->price) : 'Liên hệ' }}
</span>
<span class="text-primary font-medium">/ tháng</span>
@if ($displayPricing)
<span class="text-primary font-medium">/ tháng</span>
@endif
</div>
</div>
<!-- Duration Selector -->
@@ -44,143 +67,243 @@
<div class="mb-6">
<!-- Custom Dropdown -->
<div class="custom-dropdown" id="durationDropdown{{ $item->id }}">
@php
// Default to semi_annually (6 months) if available, otherwise first available
$defaultPricing = $semiAnnuallyPricing ?? $item->pricings->first();
$defaultPrice = $defaultPricing
? floatval($defaultPricing->price_sale ?: $defaultPricing->price)
: 0;
$defaultMonths = match ($defaultPricing?->billing_cycle) {
'semi_annually' => 6,
'annually' => 12,
'biennially' => 24,
'triennially' => 36,
default => 1,
};
@endphp
<div class="dropdown-selected">
<span class="selected-text">{{ format_price($basePrice * 6) }} / 6 tháng</span>
<span class="selected-text">
@if ($defaultPricing)
{{ format_price($defaultPrice) }} / {{ $defaultMonths }}
{{ $defaultMonths > 1 ? 'tháng' : 'tháng' }}
@else
Chọn thời gian
@endif
</span>
<i class="dropdown-arrow">▼</i>
</div>
<div class="dropdown-options">
<div class="dropdown-option selected" data-value="6" data-months="6"
data-price="{{ $basePrice * 6 }}">
<div class="flex justify-between items-center">
<span>{{ format_price($basePrice * 6) }} / 6 tháng</span>
{{-- Monthly --}}
<div class="dropdown-option {{ !$monthlyPricing ? 'disabled opacity-50 cursor-not-allowed' : '' }} {{ $defaultPricing?->billing_cycle === 'monthly' ? 'selected' : '' }}"
@if ($monthlyPricing) data-value="monthly"
data-months="1"
data-price="{{ floatval($monthlyPricing->price_sale ?: $monthlyPricing->price) }}" @endif>
<div class="flex items-center justify-between">
<span>
@if ($monthlyPricing)
{{ format_price($monthlyPricing->price_sale ?: $monthlyPricing->price) }}
/ 1 tháng
@else
<span class="text-gray-400">1 tháng - Liên hệ</span>
@endif
</span>
</div>
</div>
<div class="dropdown-option" data-value="12" data-months="12"
data-price="{{ $basePrice * 12 }}">
<div class="flex justify-between items-center">
<span>{{ format_price($basePrice * 12) }} / 1 năm</span>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">Tiết
kiệm 10%</span>
{{-- Semi-annually --}}
<div class="dropdown-option {{ !$semiAnnuallyPricing ? 'disabled opacity-50 cursor-not-allowed' : '' }} {{ $defaultPricing?->billing_cycle === 'semi_annually' ? 'selected' : '' }}"
@if ($semiAnnuallyPricing) data-value="semi_annually"
data-months="6"
data-price="{{ floatval($semiAnnuallyPricing->price_sale ?: $semiAnnuallyPricing->price) }}" @endif>
<div class="flex items-center justify-between">
<span>
@if ($semiAnnuallyPricing)
{{ format_price($semiAnnuallyPricing->price_sale ?: $semiAnnuallyPricing->price) }}
/ 6 tháng
@else
<span class="text-gray-400">6 tháng - Liên hệ</span>
@endif
</span>
@if ($monthlyPricing && $semiAnnuallyPricing && $semiAnnuallyPricing->price < $monthlyPricing->price * 6)
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-600">Tiết
kiệm</span>
@endif
</div>
</div>
<div class="dropdown-option" data-value="24" data-months="24"
data-price="{{ $basePrice * 24 }}">
<div class="flex justify-between items-center">
<span>{{ format_price($basePrice * 24) }} / 2 năm</span>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">Tiết
kiệm 10%</span>
{{-- Annually --}}
<div class="dropdown-option {{ !$annuallyPricing ? 'disabled opacity-50 cursor-not-allowed' : '' }} {{ $defaultPricing?->billing_cycle === 'annually' ? 'selected' : '' }}"
@if ($annuallyPricing) data-value="annually"
data-months="12"
data-price="{{ floatval($annuallyPricing->price_sale ?: $annuallyPricing->price) }}" @endif>
<div class="flex items-center justify-between">
<span>
@if ($annuallyPricing)
{{ format_price($annuallyPricing->price_sale ?: $annuallyPricing->price) }}
/ 1 năm
@else
<span class="text-gray-400">1 năm - Liên hệ</span>
@endif
</span>
@if ($semiAnnuallyPricing && $annuallyPricing && $annuallyPricing->price < $semiAnnuallyPricing->price * 2)
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-600">Tiết
kiệm</span>
@endif
</div>
</div>
<div class="dropdown-option" data-value="36" data-months="36"
data-price="{{ $basePrice * 36 }}">
<div class="flex justify-between items-center">
<span>{{ format_price($basePrice * 36) }} / 3 năm</span>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">Tiết
kiệm 10%</span>
{{-- Biennially --}}
<div class="dropdown-option {{ !$bienniallyPricing ? 'disabled opacity-50 cursor-not-allowed' : '' }} {{ $defaultPricing?->billing_cycle === 'biennially' ? 'selected' : '' }}"
@if ($bienniallyPricing) data-value="biennially"
data-months="24"
data-price="{{ floatval($bienniallyPricing->price_sale ?: $bienniallyPricing->price) }}" @endif>
<div class="flex items-center justify-between">
<span>
@if ($bienniallyPricing)
{{ format_price($bienniallyPricing->price_sale ?: $bienniallyPricing->price) }}
/ 2 năm
@else
<span class="text-gray-400">2 năm - Liên hệ</span>
@endif
</span>
@if ($annuallyPricing && $bienniallyPricing && $bienniallyPricing->price < $annuallyPricing->price * 2)
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-600">Tiết
kiệm</span>
@endif
</div>
</div>
{{-- Triennially --}}
<div class="dropdown-option {{ !$trienniallyPricing ? 'disabled opacity-50 cursor-not-allowed' : '' }} {{ $defaultPricing?->billing_cycle === 'triennially' ? 'selected' : '' }}"
@if ($trienniallyPricing) data-value="triennially"
data-months="36"
data-price="{{ floatval($trienniallyPricing->price_sale ?: $trienniallyPricing->price) }}" @endif>
<div class="flex items-center justify-between">
<span>
@if ($trienniallyPricing)
{{ format_price($trienniallyPricing->price_sale ?: $trienniallyPricing->price) }}
/ 3 năm
@else
<span class="text-gray-400">3 năm - Liên hệ</span>
@endif
</span>
@if ($bienniallyPricing && $trienniallyPricing && $trienniallyPricing->price < $bienniallyPricing->price * 1.5)
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-600">Tiết
kiệm</span>
@endif
</div>
</div>
</div>
</div>
<input type="hidden" name="months" class="hiddenMonths" value="6">
{{-- <input type="hidden" name="total_price" class="hiddenTotalPrice"
value="{{ $basePrice }}"> --}}
<input type="hidden" name="months" class="hiddenMonths" value="{{ $defaultMonths }}">
<input type="hidden" name="billing_cycle" class="hiddenBillingCycle"
value="{{ $defaultPricing?->billing_cycle ?? 'semi_annually' }}">
</div>
<!-- Specifications -->
<div class="space-y-4 mb-6">
<div class="mb-6 space-y-4">
<ul class="space-y-1">
<!-- CPU -->
<li class="flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fas fa-microchip text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">CPU:</span>
<span class="font-bold text-gray-800 ml-2 vm-cpu"
data-template-id="{{ $item->template_id }}">{{ $item->cpu }}
vCore</span>
</div>
</li>
@if ($item->cpu)
<li class="flex items-center justify-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-microchip text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">CPU:</span>
<span class="ml-2 font-bold text-gray-800">{{ $item->cpu }} vCore</span>
</div>
</li>
@endif
<!-- RAM -->
<li class="flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fas fa-memory text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">RAM:</span>
<span class="font-bold text-gray-800 ml-2 vm-ram"
data-template-id="{{ $item->template_id }}">{{ $item->ram }} GB</span>
</div>
</li>
@if ($item->ram)
<li class="flex items-center justify-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-memory text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">RAM:</span>
<span class="ml-2 font-bold text-gray-800">{{ $item->ram }} GB</span>
</div>
</li>
@endif
<!-- SSD -->
<li class="flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fas fa-hdd text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">SSD:</span>
<span class="font-bold text-gray-800 ml-2 vm-disk"
data-template-id="{{ $item->template_id }}">{{ $item->disk }} GB</span>
</div>
</li>
@if ($item->disk)
<li class="flex items-center justify-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-hdd text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">SSD:</span>
<span class="ml-2 font-bold text-gray-800">{{ $item->disk }} GB</span>
</div>
</li>
@endif
<!-- GPU -->
<li class="flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fas fa-tv text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">GPU:</span>
<span class="font-bold text-gray-800 ml-2">{{ $item->gpu }} GB</span>
</div>
</li>
@if ($item->gpu)
<li class="flex items-center justify-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-tv text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">GPU:</span>
<span class="ml-2 font-bold text-gray-800">{{ $item->gpu }} GB</span>
</div>
</li>
@endif
<!-- Bandwidth -->
<li class="flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fas fa-tachometer-alt text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">Băng thông:</span>
<span class="font-bold text-gray-800 ml-2">{{ $item->bandwidth }} MB</span>
</div>
</li>
@if ($item->bandwidth)
<li class="flex items-center justify-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-tachometer-alt text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">Băng thông:</span>
<span class="ml-2 font-bold text-gray-800">{{ $item->bandwidth }} MB</span>
</div>
</li>
@endif
<!-- OS -->
<li class="flex items-center justify-center gap-3">
<div class="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<i class="fab fa-linux text-orange-600 os-icon"
data-template-id="{{ $item->template_id }}"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">Hệ điều hành:</span>
<span class="font-bold text-gray-800 ml-2 guest-os"
data-template-id="{{ $item->template_id }}">
@if ($item->type_id == 4 && $item->operatingSystems->isNotEmpty())
{{ $item->operatingSystems->pluck('osGroup.name')->filter()->unique()->implode(', ') }}
@else
{{ $item->os ?? 'Linux' }}
@endif
</span>
</div>
</li>
@if ($item->osVersions && $item->osVersions->isNotEmpty())
<li class="flex items-center justify-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-orange-100">
<i class="fab fa-linux os-icon text-orange-600"></i>
</div>
<div class="flex-1">
<span class="text-sm text-gray-600">Hệ điều hành:</span>
<span class="guest-os ml-2 font-bold text-gray-800">
{{ $item->osVersions->pluck('name')->unique()->implode(', ') }}
</span>
</div>
</li>
@endif
</ul>
</div>
<!-- Action Button -->
<flux:button variant="primary" color="orange" type="submit" class="w-full"
icon="shopping-bag">
<flux:button variant="primary" color="orange" type="submit" class="w-full" icon="shopping-bag">
Đăng ký ngay</flux:button>
</div>
</div>
</form>
@empty
<div class="col-span-full">
<div class="text-center py-16">
<div class="w-24 h-24 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="py-16 text-center">
<div class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-box-open text-4xl text-orange-500"></i>
</div>
<h3 class="text-2xl font-bold text-gray-800 mb-2">Không có sản phẩm</h3>
<h3 class="mb-2 text-2xl font-bold text-gray-800">Không có sản phẩm</h3>
<p class="text-gray-600">Danh mục này hiện chưa có sản phẩm nào.</p>
</div>
</div>
@@ -188,15 +311,15 @@
@endforelse
</div>
@else
<div class="min-h-screen flex items-center justify-center">
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div class="w-32 h-32 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="mx-auto mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-orange-100">
<i class="fas fa-exclamation-triangle text-5xl text-orange-500"></i>
</div>
<h2 class="text-3xl font-bold text-gray-800 mb-4">Không tìm thấy</h2>
<p class="text-gray-600 text-lg">Danh mục này không tồn tại hoặc đã bị xóa.</p>
<h2 class="mb-4 text-3xl font-bold text-gray-800">Không tìm thấy</h2>
<p class="text-lg text-gray-600">Danh mục này không tồn tại hoặc đã bị xóa.</p>
<a href="{{ route('home') }}"
class="inline-block mt-6 bg-orange-500 hover:bg-orange-600 text-white font-bold py-3 px-6 rounded-xl transition-colors">
class="mt-6 inline-block rounded-xl bg-orange-500 px-6 py-3 font-bold text-white transition-colors hover:bg-orange-600">
Về trang chủ
</a>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ use App\Http\Controllers\frontend\ShopController;
use App\Http\Controllers\frontend\TicketController;
use App\Http\Controllers\frontend\Order\OrderController as ClientOrderController;
use App\Http\Controllers\frontend\UserController as ClientController;
use Illuminate\Support\Facades\Route;
Route::middleware("IsLoginCheck")
->controller(ClientController::class)