update cart view
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user