update cart view

This commit is contained in:
pbao9
2025-07-29 08:53:41 +07:00
parent 5ea42105f9
commit 980c9468fc
18 changed files with 893 additions and 409 deletions
+127
View File
@@ -0,0 +1,127 @@
/**
* Cart Skeleton Loading Utility
* Manages loading states for cart components
*/
class CartSkeletonManager {
constructor() {
this.loadingStates = new Map();
this.defaultLoadingTime = 1000; // 1 second
}
/**
* Show skeleton loading for a component
* @param {string} componentId - Component identifier
* @param {number} duration - Loading duration in ms
*/
showSkeleton(componentId, duration = this.defaultLoadingTime) {
this.loadingStates.set(componentId, true);
this.updateComponentState(componentId, true);
// Auto hide after duration
setTimeout(() => {
this.hideSkeleton(componentId);
}, duration);
}
/**
* Hide skeleton loading for a component
* @param {string} componentId - Component identifier
*/
hideSkeleton(componentId) {
this.loadingStates.set(componentId, false);
this.updateComponentState(componentId, false);
}
/**
* Update component loading state
* @param {string} componentId - Component identifier
* @param {boolean} isLoading - Loading state
*/
updateComponentState(componentId, isLoading) {
const component = document.querySelector(`[data-skeleton-id="${componentId}"]`);
if (component && component._x_dataStack) {
const alpineData = component._x_dataStack[0];
if (alpineData && typeof alpineData.loading !== 'undefined') {
alpineData.loading = isLoading;
}
}
}
/**
* Show skeleton for multiple components
* @param {Array} componentIds - Array of component identifiers
* @param {number} duration - Loading duration in ms
*/
showMultipleSkeletons(componentIds, duration = this.defaultLoadingTime) {
componentIds.forEach(id => this.showSkeleton(id, duration));
}
/**
* Hide skeleton for multiple components
* @param {Array} componentIds - Array of component identifiers
*/
hideMultipleSkeletons(componentIds) {
componentIds.forEach(id => this.hideSkeleton(id));
}
/**
* Check if component is loading
* @param {string} componentId - Component identifier
* @returns {boolean} Loading state
*/
isLoading(componentId) {
return this.loadingStates.get(componentId) || false;
}
/**
* Simulate loading for cart initialization
*/
simulateCartLoading() {
const components = ['domain-input', 'product-details', 'os-selection', 'addons-summary'];
this.showMultipleSkeletons(components, 800);
}
/**
* Simulate loading for OS selection
*/
simulateOsLoading() {
this.showSkeleton('os-selection', 600);
}
/**
* Simulate loading for addons
*/
simulateAddonsLoading() {
this.showSkeleton('addons-summary', 500);
}
}
// Global instance
window.cartSkeleton = new CartSkeletonManager();
// Alpine.js plugin for skeleton loading
document.addEventListener('alpine:init', () => {
Alpine.plugin('skeleton', (Alpine) => {
Alpine.magic('skeleton', () => ({
show: (componentId, duration) => window.cartSkeleton.showSkeleton(componentId, duration),
hide: (componentId) => window.cartSkeleton.hideSkeleton(componentId),
isLoading: (componentId) => window.cartSkeleton.isLoading(componentId)
}));
});
});
// Auto-initialize skeleton loading for cart page
document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('[data-cart-page]')) {
// Simulate initial loading
setTimeout(() => {
window.cartSkeleton.simulateCartLoading();
}, 100);
}
});
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = CartSkeletonManager;
}
+99 -340
View File
@@ -2,93 +2,7 @@
@push('viewStyles')
<link href="{{ asset('css/service/dropdown.css') }}" rel="stylesheet">
<style>
.custom-dropdown {
position: relative;
width: 100%;
z-index: 1000;
}
.dropdown-selected {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
cursor: pointer !important;
transition: all 0.2s ease;
font-weight: 500;
user-select: none;
position: relative;
z-index: 1001;
}
.dropdown-selected:hover {
border-color: #f66d2f;
box-shadow: 0 0 0 3px rgba(246, 109, 47, 0.1);
}
.dropdown-selected.active {
border-color: #f66d2f;
box-shadow: 0 0 0 3px rgba(246, 109, 47, 0.1);
}
.dropdown-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 2px solid #f66d2f;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 1002;
max-height: 200px;
overflow-y: auto;
margin-top: 4px;
display: none;
}
.dropdown-options.show {
display: block !important;
}
.dropdown-option {
padding: 12px 16px;
cursor: pointer !important;
transition: all 0.2s ease;
border-bottom: 1px solid #f3f4f6;
user-select: none;
}
.dropdown-option:hover {
background: #fff5f0;
color: #f66d2f;
}
.dropdown-option.selected {
background: #f66d2f;
color: white;
}
.dropdown-option:last-child {
border-bottom: none;
}
.dropdown-arrow {
transition: transform 0.2s ease;
color: #6b7280;
font-size: 12px;
}
.dropdown-selected.active .dropdown-arrow,
.dropdown-arrow.rotate-180 {
transform: rotate(180deg);
color: #f66d2f;
}
</style>
<link href="{{ asset('css/service/cart.css') }}" rel="stylesheet">
@endpush
@section('content')
@@ -103,15 +17,7 @@
<h1 class="text-3xl font-bold text-gray-800">Cấu hình tùy chọn và tiếp tục thanh toán</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8" x-data="{
paymentPeriod: {{ $cartItems[0]['months'] ?? 6 }},
baseTotal: {{ $cartItems[0]['product']->price_sale ?? ($cartItems[0]['product']->price ?? 0) }},
get totalPrice() {
const originalPrice = this.baseTotal * this.paymentPeriod;
const discount = this.paymentPeriod >= 12 ? Math.round(originalPrice * 0.1) : 0;
return originalPrice - discount;
}
}">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8" data-cart-page x-data="cartForm()" x-init="initializeSkeleton()" x-cloak>
<!-- Left Column - Product Configuration -->
<div class="space-y-6">
@foreach ($cartItems as $index => $item)
@@ -125,233 +31,20 @@
<input type="hidden" name="months" value="{{ $item['months'] }}">
<!-- Domain Input Section -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Domain</h3>
<div x-data="{ serverName: '', isValid: true }">
<flux:input name="server_name" placeholder="Domain" x-model="serverName"
@input="isValid = /^[a-zA-Z0-9.-]+$/.test(serverName)">
<x-slot name="iconTrailing">
<flux:button size="sm" variant="subtle" icon="x-mark" class="-mr-1"
@click="serverName = ''; isValid = true" type="button" />
</x-slot>
</flux:input>
@include('frontend.cart.partials.domain-input')
<template x-if="!isValid">
<div class="p-4 my-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400"
role="alert">
Tên server không hợp lệ. Chỉ được dùng chữ, số, dấu chấm và dấu gạch ngang.
<b>VD: tenmiencuaban.com.vn</b>
</div>
</template>
<p class="text-xs text-red-600 mt-2">
Lưu ý: Quý khách chỉ ghi tên Domain, không cần ghi đầy đủ tiền tố http:// hoặc
https://
</p>
</div>
</div>
<!-- Product Title and Price -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<input type="hidden" name="product_id" value="{{ $product->id }}" />
<input type="hidden" name="product_type" value="{{ $item['product_type'] }}" />
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-800 border-b-2 border-blue-500 pb-2">
{{ $product->name }}
@if ($item['product_type'] == 4)
<span class="text-sm text-gray-500">(VPS)</span>
@elseif ($item['product_type'] == 3)
<span class="text-sm text-gray-500">(Hosting)</span>
@endif
</h2>
<div class="text-right">
@if ($product->price_sale && $product->price_sale < $product->price)
<div class="text-lg font-bold text-red-600">
{{ format_price($product->price_sale) }}/tháng
</div>
<div class="text-sm text-gray-400 line-through">
{{ format_price($product->price) }}/tháng
</div>
@else
<div class="text-lg font-bold text-gray-800">
{{ format_price($product->price) }}/tháng
</div>
@endif
</div>
</div>
<!-- Product Features -->
<div class="space-y-2 text-sm text-gray-700">
@if ($item['product_type'] == 3)
<!-- Hosting -->
<div>• {{ number_format($product->disk ?? 2000) }} MB dung lượng SSD.</div>
<div>• 1 Addon domain.</div>
<div>• 10 Sub-domains.</div>
<div>• Không giới hạn Alias Domain.</div>
<div>• Không giới hạn Database.</div>
<div>• Không giới hạn tài khoản FTP.</div>
<div>• Control Panel: Cpanel.</div>
@else
<!-- VPS Features -->
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/cpu.svg') }}" alt="CPU" class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">CPU:</span>
<div class="font-semibold text-blue-800">
<span>{{ $product->cpu ?? 'Không rõ' }}</span>
<span class="text-xs text-blue-600">vCore</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/ram.svg') }}" alt="Ram" class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">RAM:</span>
<div class="font-semibold text-blue-800">
<span>{{ $product->ram ?? 'Không rõ' }}</span>
<span class="text-xs text-blue-600">GB</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/storage.svg') }}" alt="Disk" class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">Disk:</span>
<div class="font-semibold text-blue-800">
<span>{{ $product->disk ?? 'Không rõ' }}</span>
<span class="text-xs text-blue-600">GB</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/video.svg') }}" alt="GPU" class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">GPU:</span>
<div class="font-semibold text-blue-800">
<span>{{ $product->gpu ?? 'Không rõ' }}</span>
<span class="text-xs text-blue-600">GB</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/network.svg') }}" alt="IP" class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">IP Public:</span>
<div class="font-semibold text-blue-800">
<span>{{ $product->ip ?? 'Không rõ' }}</span>
<span class="text-xs text-blue-600">IP</span>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/bandwidth.svg') }}" alt="Bandwidth"
class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">Băng thông:</span>
<div class="font-semibold text-blue-800">
<span>{{ $product->bandwidth ?? 'Không rõ' }}</span>
<span class="text-xs text-blue-600">GB</span>
</div>
</div>
</div>
@endif
</div>
</div>
<!-- Product Details -->
@include('frontend.cart.partials.product-details', [
'product' => $product,
'item' => $item
])
<!-- OS Selection for VPS -->
@if ($item['product_type'] == 4 && isset($item['os_group']) && count($item['os_group']) > 0)
<div class="bg-white rounded-lg border border-gray-200 p-6" x-cloak
x-data="{ selectedOs: null, showOsDetails: false }">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Hệ điều hành</h3>
<div class="space-y-4">
@foreach ($item['os_group'] as $group)
<div>
<div class="flex items-center gap-2 mb-2">
<img src="{{ $group->thumbnail }}" alt="{{ $group->name }}"
class="w-6 h-6 rounded" />
<span class="font-medium text-gray-700">{{ $group->name }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
@foreach ($group->os as $os)
<div class="relative">
<button type="button"
@click="selectedOs = {{ $os->id }}; $dispatch('os-changed', {{ $os->id }}); showOsDetails = true"
:class="{
'border-orange-500 ring-2 ring-orange-400 bg-orange-50 text-orange-700': selectedOs ===
{{ $os->id }},
'border-gray-200 bg-white text-gray-700 hover:bg-gray-50': selectedOs !==
{{ $os->id }}
}"
class="border rounded-xl p-3 text-sm font-medium transition-all text-left flex items-center gap-3 w-full relative">
<img src="{{ $os->thumbnail ?? $group->thumbnail }}"
alt="{{ $os->version }}"
class="w-8 h-8 rounded object-cover" />
<div>
<div class="font-semibold">{{ $group->name }}</div>
<div class="text-xs text-gray-500">Version:
{{ $os->version }}</div>
</div>
<div x-show="selectedOs === {{ $os->id }}"
class="absolute top-1 right-1 bg-orange-500 text-white rounded-full p-0.5">
<svg class="w-4 h-4" fill="none"
stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</button>
</div>
@endforeach
</div>
</div>
@endforeach
</div>
<!-- OS Details Panel -->
<div x-show="showOsDetails && selectedOs"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="mt-4 p-4 bg-orange-50 rounded-lg border border-orange-200">
<div class="flex items-center justify-between mb-2">
<h5 class="font-semibold text-orange-800">Thông tin hệ điều hành</h5>
<flux:button size="sm" variant="subtle" icon="x-mark"
@click="showOsDetails = false" type="button" />
</div>
<p class="text-sm text-orange-700">
Bạn đã chọn hệ điều hành. Hệ điều hành sẽ được cài đặt tự động khi máy chủ được
tạo.
</p>
</div>
<input type="hidden" name="os_id" x-bind:value="selectedOs"
wire:model.defer="selectedOsList.{{ $index }}">
</div>
@include('frontend.cart.partials.os-select', [
'item' => $item,
'index' => $index
])
@endif
@endforeach
</div>
@@ -374,33 +67,99 @@
</div>
</div>
@else
<div class="text-center py-16">
<div class="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4m0 0L7 13m0 0l-2.5 5M7 13l2.5 5m6-5v6a2 2 0 01-2 2H9a2 2 0 01-2-2v-6m8 0V9a2 2 0 00-2-2H9a2 2 0 00-2 2v4.01">
</path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-800 mb-2">Giỏ hàng trống</h3>
<p class="text-gray-600 mb-6">Bạn chưa có sản phẩm nào trong giỏ hàng.</p>
<a href="{{ route('shop') }}"
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Mua sản phẩm
</a>
</div>
@include('frontend.cart.partials.empty-cart')
@endif
</div>
</form>
@endsection
@push('viewScripts')
<script src="{{ asset('js/cart-skeleton.min.js') }}"></script>
<script>
// Alpine.js is now handling all dropdown functionality
console.log('Alpine.js dropdown initialized');
function cartForm() {
return {
isFormValid: true,
init() {
this.validateForm();
},
initializeSkeleton() {
// Add loading class to body
document.body.classList.add('skeleton-active');
// Show skeleton immediately for all components
this.showAllSkeletons();
// Hide skeleton after a delay to simulate loading
setTimeout(() => {
this.hideAllSkeletons();
document.body.classList.remove('skeleton-active');
}, 1500);
},
showAllSkeletons() {
// Show skeleton for all components
const components = document.querySelectorAll('[data-skeleton-id]');
components.forEach(component => {
if (component._x_dataStack && component._x_dataStack[0]) {
const data = component._x_dataStack[0];
if (data.loading !== undefined) {
data.loading = true;
}
}
});
// Also handle nested components without data-skeleton-id
this.updateNestedComponents(true);
},
hideAllSkeletons() {
// Hide skeleton for all components
const components = document.querySelectorAll('[data-skeleton-id]');
components.forEach(component => {
if (component._x_dataStack && component._x_dataStack[0]) {
const data = component._x_dataStack[0];
if (data.loading !== undefined) {
data.loading = false;
}
}
});
// Also handle nested components without data-skeleton-id
this.updateNestedComponents(false);
},
updateNestedComponents(showSkeleton) {
// Update all Alpine components with loading state
const allComponents = document.querySelectorAll('[x-data]');
allComponents.forEach(component => {
if (component._x_dataStack && component._x_dataStack[0]) {
const data = component._x_dataStack[0];
if (data.loading !== undefined) {
data.loading = showSkeleton;
}
}
});
},
validateForm() {
// Validate domain name
const domainInput = document.querySelector('input[name="server_name"]');
if (domainInput) {
const domainValue = domainInput.value;
const domainPattern = /^[a-zA-Z0-9.-]+$/;
this.isFormValid = domainPattern.test(domainValue);
}
// Validate OS selection for VPS
const osInput = document.querySelector('input[name="os_id"]');
if (osInput && !osInput.value) {
this.isFormValid = false;
}
}
};
}
</script>
@endpush
@@ -0,0 +1,45 @@
<div class="bg-white rounded-lg border border-gray-200 p-6" x-data="{ loading: true }" data-skeleton-id="domain-input">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('Domain') }}</h3>
<!-- Skeleton Loading -->
<template x-if="loading">
<div class="space-y-4 animate-pulse">
<div class="h-12 bg-gray-200 rounded-lg"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</template>
<!-- Actual Content -->
<template x-if="!loading">
<div x-data="{ serverName: '', isValid: true }">
<flux:input
name="server_name"
placeholder="{{ __('Nhập tên domain') }}"
x-model="serverName"
@input="isValid = /^[a-zA-Z0-9.-]+$/.test(serverName)"
>
<x-slot name="iconTrailing">
<flux:button
size="sm"
variant="subtle"
icon="x-mark"
class="-mr-1"
@click="serverName = ''; isValid = true"
type="button"
/>
</x-slot>
</flux:input>
<template x-if="!isValid">
<div class="p-4 my-4 text-sm text-red-800 rounded-lg bg-red-50" role="alert">
{{ __('Tên server không hợp lệ. Chỉ được dùng chữ, số, dấu chấm và dấu gạch ngang.') }}
<b>{{ __('VD: tenmiencuaban.com.vn') }}</b>
</div>
</template>
<p class="text-xs text-red-600 mt-2">
{{ __('Lưu ý: Quý khách chỉ ghi tên Domain, không cần ghi đầy đủ tiền tố http:// hoặc https://') }}
</p>
</div>
</template>
</div>
@@ -0,0 +1,34 @@
<div class="text-center py-16" x-data="{ loading: true }">
<!-- Skeleton Loading -->
<template x-if="loading">
<div class="animate-pulse">
<div class="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-6"></div>
<div class="h-6 bg-gray-200 rounded w-48 mx-auto mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-64 mx-auto mb-6"></div>
<div class="h-12 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
</template>
<!-- Actual Empty Cart Content -->
<template x-if="!loading">
<div>
<div class="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4m0 0L7 13m0 0l-2.5 5M7 13l2.5 5m6-5v6a2 2 0 01-2 2H9a2 2 0 01-2-2v-6m8 0V9a2 2 0 00-2-2H9a2 2 0 00-2 2v4.01">
</path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-800 mb-2">{{ __('Giỏ hàng trống') }}</h3>
<p class="text-gray-600 mb-6">{{ __('Bạn chưa có sản phẩm nào trong giỏ hàng.') }}</p>
<a href="{{ route('shop') }}"
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ __('Mua sản phẩm') }}
</a>
</div>
</template>
</div>
@@ -0,0 +1,99 @@
<div class="bg-white rounded-lg border border-gray-200 p-6" x-cloak x-data="{ selectedOs: null, showOsDetails: false, loading: true }" data-skeleton-id="os-selection">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('Hệ điều hành') }}</h3>
<!-- Skeleton Loading -->
<template x-if="loading">
<div class="space-y-6 animate-pulse">
<!-- OS Group Skeleton -->
<div class="space-y-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-gray-200 rounded"></div>
<div class="h-4 bg-gray-200 rounded w-24"></div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div class="h-20 bg-gray-200 rounded-xl"></div>
<div class="h-20 bg-gray-200 rounded-xl"></div>
<div class="h-20 bg-gray-200 rounded-xl"></div>
</div>
</div>
<!-- Second OS Group Skeleton -->
<div class="space-y-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-gray-200 rounded"></div>
<div class="h-4 bg-gray-200 rounded w-20"></div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div class="h-20 bg-gray-200 rounded-xl"></div>
<div class="h-20 bg-gray-200 rounded-xl"></div>
</div>
</div>
</div>
</template>
<!-- Actual Content -->
<template x-if="!loading">
<div class="space-y-4">
@foreach ($item['os_group'] as $group)
<div>
<div class="flex items-center gap-2 mb-2">
<img src="{{ $group->thumbnail }}" alt="{{ $group->name }}" class="w-6 h-6 rounded" />
<span class="font-medium text-gray-700">{{ $group->name }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
@foreach ($group->os as $os)
<div class="relative">
<button type="button"
@click="selectedOs = {{ $os->id }}; $dispatch('os-changed', {{ $os->id }}); showOsDetails = true"
:class="{
'border-orange-500 ring-2 ring-orange-400 bg-orange-50 text-orange-700': selectedOs ===
{{ $os->id }},
'border-gray-200 bg-white text-gray-700 hover:bg-gray-50': selectedOs !==
{{ $os->id }}
}"
class="border rounded-xl p-3 text-sm font-medium transition-all text-left flex items-center gap-3 w-full relative">
<img src="{{ $os->thumbnail ?? $group->thumbnail }}" alt="{{ $os->version }}"
class="w-8 h-8 rounded object-cover" />
<div>
<div class="font-semibold">{{ $group->name }}</div>
<div class="text-xs text-gray-500">{{ __('Version') }}:
{{ $os->version }}</div>
</div>
<div x-show="selectedOs === {{ $os->id }}"
class="absolute top-1 right-1 bg-orange-500 text-white rounded-full p-0.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</button>
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</template>
<!-- OS Details Panel -->
<div x-show="showOsDetails && selectedOs" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="mt-4 p-4 bg-orange-50 rounded-lg border border-orange-200">
<div class="flex items-center justify-between mb-2">
<h5 class="font-semibold text-orange-800">{{ __('Thông tin hệ điều hành') }}</h5>
<flux:button size="sm" variant="subtle" icon="x-mark" @click="showOsDetails = false" type="button" />
</div>
<p class="text-sm text-orange-700">
{{ __('Bạn đã chọn hệ điều hành. Hệ điều hành sẽ được cài đặt tự động khi máy chủ được tạo.') }}
</p>
</div>
<input type="hidden" name="os_id" x-bind:value="selectedOs"
wire:model.defer="selectedOsList.{{ $index }}">
</div>
@@ -0,0 +1,70 @@
<div class="bg-white rounded-lg border border-gray-200 p-6" x-data="{ loading: true }" data-skeleton-id="product-details">
<input type="hidden" name="product_id" value="{{ $product->id }}" />
<input type="hidden" name="product_type" value="{{ $item['product_type'] }}" />
<!-- Skeleton Loading -->
<template x-if="loading">
<div class="animate-pulse">
<div class="flex items-center justify-between mb-4">
<div class="h-6 bg-gray-200 rounded w-1/3"></div>
<div class="h-6 bg-gray-200 rounded w-1/4"></div>
</div>
<div class="space-y-3">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
<div class="h-4 bg-gray-200 rounded w-4/6"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</template>
<!-- Actual Content -->
<template x-if="!loading">
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-800 border-b-2 border-blue-500 pb-2">
{{ $product->name }}
@if ($item['product_type'] == 4)
<span class="text-sm text-gray-500">(VPS)</span>
@elseif ($item['product_type'] == 3)
<span class="text-sm text-gray-500">(Hosting)</span>
@endif
</h2>
<div class="text-right">
@if ($product->price_sale && $product->price_sale < $product->price)
<div class="text-lg font-bold text-red-600">
{{ format_price($product->price_sale) }}/{{ __('tháng') }}
</div>
<div class="text-sm text-gray-400 line-through">
{{ format_price($product->price) }}/{{ __('tháng') }}
</div>
@else
<div class="text-lg font-bold text-gray-800">
{{ format_price($product->price) }}/{{ __('tháng') }}
</div>
@endif
</div>
</div>
<!-- Product Features -->
<div class="space-y-2 text-sm text-gray-700">
@if ($item['product_type'] == 3)
<!-- Hosting Features -->
<div>• {{ number_format($product->disk ?? 2000) }} MB {{ __('dung lượng SSD') }}.</div>
<div>• 1 {{ __('Addon domain') }}.</div>
<div>• 10 {{ __('Sub-domains') }}.</div>
<div>• {{ __('Không giới hạn Alias Domain') }}.</div>
<div>• {{ __('Không giới hạn Database') }}.</div>
<div>• {{ __('Không giới hạn tài khoản FTP') }}.</div>
<div>• {{ __('Control Panel') }}: Cpanel.</div>
@else
<!-- VPS Features -->
@include('frontend.cart.partials.vps-features', [
'product' => $product,
'parentLoading' => 'loading'
])
@endif
</div>
</div>
</template>
</div>
@@ -0,0 +1,28 @@
@props(['type' => 'default', 'lines' => 3, 'height' => 'h-4'])
@php
$skeletonTypes = [
'text' => 'h-4 bg-gray-200 rounded',
'title' => 'h-6 bg-gray-200 rounded',
'button' => 'h-10 bg-gray-200 rounded-lg',
'input' => 'h-12 bg-gray-200 rounded-lg',
'card' => 'h-32 bg-gray-200 rounded-lg',
'avatar' => 'w-8 h-8 bg-gray-200 rounded-full',
'icon' => 'w-5 h-5 bg-gray-200 rounded',
'image' => 'w-8 h-8 bg-gray-200 rounded object-cover',
];
$skeletonClass = $skeletonTypes[$type] ?? $skeletonTypes['text'];
@endphp
<div class="animate-pulse">
@if ($type === 'card')
<div class="{{ $skeletonClass }}"></div>
@elseif ($type === 'avatar' || $type === 'icon' || $type === 'image')
<div class="{{ $skeletonClass }}"></div>
@else
@for ($i = 0; $i < $lines; $i++)
<div class="{{ $skeletonClass }} {{ $i === $lines - 1 ? 'w-3/4' : 'w-full' }} {{ $i > 0 ? 'mt-2' : '' }}"></div>
@endfor
@endif
</div>
@@ -0,0 +1,49 @@
@php
$features = [
'cpu' => ['icon' => 'cpu.svg', 'label' => 'CPU', 'unit' => 'vCore'],
'ram' => ['icon' => 'ram.svg', 'label' => 'RAM', 'unit' => 'GB'],
'disk' => ['icon' => 'storage.svg', 'label' => 'Disk', 'unit' => 'GB'],
'gpu' => ['icon' => 'video.svg', 'label' => 'GPU', 'unit' => 'GB'],
'ip' => ['icon' => 'network.svg', 'label' => 'IP Public', 'unit' => 'IP'],
'bandwidth' => ['icon' => 'bandwidth.svg', 'label' => 'Băng thông', 'unit' => 'GB']
];
@endphp
<div x-data="{ loading: true }" data-skeleton-id="vps-features">
<!-- Skeleton Loading for VPS Features -->
<template x-if="loading">
<div class="space-y-3 animate-pulse">
@foreach (range(1, 6) as $i)
<div class="flex items-center space-x-3">
<div class="w-5 h-5 bg-gray-200 rounded"></div>
<div class="flex items-center gap-2">
<div class="h-4 bg-gray-200 rounded w-16"></div>
<div class="h-4 bg-gray-200 rounded w-12"></div>
</div>
</div>
@endforeach
</div>
</template>
<!-- Actual VPS Features -->
<template x-if="!loading">
<div>
@foreach ($features as $key => $feature)
@if ($product->$key)
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<img src="{{ asset('/icon/' . $feature['icon']) }}" alt="{{ $feature['label'] }}" class="w-5 h-5">
</div>
<div class="flex items-center gap-2">
<span class="text-md font-semibold">{{ $feature['label'] }}:</span>
<div class="font-semibold text-blue-800 h-4">
<span>{{ $product->$key }}</span>
<span class="text-xs text-blue-600">{{ $feature['unit'] }}</span>
</div>
</div>
</div>
@endif
@endforeach
</div>
</template>
</div>
@@ -2,7 +2,7 @@
$basePrice = $product->price_sale ?: $product->price;
@endphp
<div x-data="cartHandler({{ json_encode($addons) }}, {{ $basePrice }}, {{ $months }})">
<div x-data="cartHandler({{ json_encode($addons) }}, {{ $basePrice }}, {{ $months }})" data-skeleton-id="addons-summary">
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('Thời hạn') }}</h3>
<div class="relative">
@@ -62,10 +62,23 @@
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{ __('Cấu hình tùy chọn') }}</h3>
<template x-if="loading">
<div class="space-y-4 animate-pulse">
<div class="h-20 bg-gray-200 rounded-lg"></div>
<div class="h-20 bg-gray-200 rounded-lg"></div>
<div class="h-20 bg-gray-200 rounded-lg"></div>
<div class="space-y-4">
@for ($i = 0; $i < 3; $i++)
<div class="p-4 bg-gray-50 rounded-lg animate-pulse">
<div class="flex justify-between items-center mb-2">
<div class="h-4 bg-gray-200 rounded w-24"></div>
<div class="h-4 bg-gray-200 rounded w-20"></div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<div class="w-10 h-10 bg-gray-200"></div>
<div class="w-16 h-10 bg-gray-200"></div>
<div class="w-10 h-10 bg-gray-200"></div>
</div>
<div class="h-4 bg-gray-200 rounded w-16"></div>
</div>
</div>
@endfor
</div>
</template>
<template x-if="!loading">
@@ -227,7 +240,7 @@
step: addon.step || 1,
}));
this.loading = false;
}, 500);
}, 1500); // Sync with other components
}
};
}