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
+9 -57
View File
@@ -81,7 +81,6 @@
--color-emerald-800: oklch(43.2% 0.095 166.913);
--color-emerald-900: oklch(37.8% 0.077 168.94);
--color-teal-50: oklch(98.4% 0.014 180.72);
--color-teal-100: oklch(95.3% 0.051 180.801);
--color-teal-200: oklch(91% 0.096 180.426);
--color-teal-300: oklch(85.5% 0.138 181.071);
--color-teal-400: oklch(77.7% 0.152 181.912);
@@ -165,7 +164,6 @@
--color-pink-800: oklch(45.9% 0.187 3.815);
--color-pink-900: oklch(40.8% 0.153 2.432);
--color-rose-50: oklch(96.9% 0.015 12.422);
--color-rose-100: oklch(94.1% 0.03 12.58);
--color-rose-200: oklch(89.2% 0.058 10.001);
--color-rose-300: oklch(81% 0.117 11.638);
--color-rose-400: oklch(71.2% 0.194 13.428);
@@ -1506,12 +1504,21 @@
.w-3 {
width: calc(var(--spacing) * 3);
}
.w-3\/4 {
width: calc(3/4 * 100%);
}
.w-4 {
width: calc(var(--spacing) * 4);
}
.w-4\/6 {
width: calc(4/6 * 100%);
}
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-5\/6 {
width: calc(5/6 * 100%);
}
.w-6 {
width: calc(var(--spacing) * 6);
}
@@ -2553,9 +2560,6 @@
border-color: color-mix(in oklab, var(--color-gray-500) 40%, transparent);
}
}
.border-green-100 {
border-color: var(--color-green-100);
}
.border-green-200 {
border-color: var(--color-green-200);
}
@@ -2622,9 +2626,6 @@
border-color: color-mix(in oklab, var(--color-neutral-500) 40%, transparent);
}
}
.border-orange-100 {
border-color: var(--color-orange-100);
}
.border-orange-200 {
border-color: var(--color-orange-200);
}
@@ -2670,9 +2671,6 @@
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
}
}
.border-purple-100 {
border-color: var(--color-purple-100);
}
.border-purple-200 {
border-color: var(--color-purple-200);
}
@@ -2703,9 +2701,6 @@
border-color: color-mix(in oklab, var(--color-red-500) 40%, transparent);
}
}
.border-rose-100 {
border-color: var(--color-rose-100);
}
.border-rose-200 {
border-color: var(--color-rose-200);
}
@@ -2766,9 +2761,6 @@
border-color: color-mix(in oklab, var(--color-stone-500) 40%, transparent);
}
}
.border-teal-100 {
border-color: var(--color-teal-100);
}
.border-teal-200 {
border-color: var(--color-teal-200);
}
@@ -3606,10 +3598,6 @@
--tw-gradient-from: var(--color-primary);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-blue-50 {
--tw-gradient-from: var(--color-blue-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-blue-600 {
--tw-gradient-from: var(--color-blue-600);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
@@ -3618,50 +3606,18 @@
--tw-gradient-from: var(--color-gray-100);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-green-50 {
--tw-gradient-from: var(--color-green-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-orange-50 {
--tw-gradient-from: var(--color-orange-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-purple-50 {
--tw-gradient-from: var(--color-purple-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-rose-50 {
--tw-gradient-from: var(--color-rose-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.from-teal-50 {
--tw-gradient-from: var(--color-teal-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-\[var\(--color-secondary\)\] {
--tw-gradient-to: var(--color-secondary);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-amber-50 {
--tw-gradient-to: var(--color-amber-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-cyan-50 {
--tw-gradient-to: var(--color-cyan-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-emerald-50 {
--tw-gradient-to: var(--color-emerald-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-indigo-50 {
--tw-gradient-to: var(--color-indigo-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-pink-50 {
--tw-gradient-to: var(--color-pink-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-purple-600 {
--tw-gradient-to: var(--color-purple-600);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
@@ -3670,10 +3626,6 @@
--tw-gradient-to: transparent;
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.to-violet-50 {
--tw-gradient-to: var(--color-violet-50);
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}
.bg-cover {
background-size: cover;
}
+307
View File
@@ -0,0 +1,307 @@
/* Cart Page Styles */
.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,
.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;
}
/* Product Feature Icons */
.product-feature {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.product-feature-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.product-feature-content {
display: flex;
align-items: center;
gap: 8px;
}
.product-feature-label {
font-weight: 600;
color: #374151;
}
.product-feature-value {
font-weight: 600;
color: #1e40af;
}
.product-feature-unit {
font-size: 12px;
color: #2563eb;
}
/* OS Selection */
.os-group {
margin-bottom: 16px;
}
.os-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.os-group-thumbnail {
width: 24px;
height: 24px;
border-radius: 4px;
}
.os-group-name {
font-weight: 500;
color: #374151;
}
.os-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.os-option {
position: relative;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 12px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
text-align: left;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
background: white;
color: #374151;
}
.os-option:hover {
background: #f9fafb;
}
.os-option.selected {
border-color: #f66d2f;
background: #fff7ed;
color: #ea580c;
box-shadow: 0 0 0 3px rgba(246, 109, 47, 0.1);
}
.os-option-thumbnail {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
}
.os-option-content {
flex: 1;
}
.os-option-name {
font-weight: 600;
}
.os-option-version {
font-size: 12px;
color: #6b7280;
}
.os-option-check {
position: absolute;
top: 4px;
right: 4px;
background: #f66d2f;
color: white;
border-radius: 50%;
padding: 2px;
width: 16px;
height: 16px;
}
/* Empty Cart */
.empty-cart {
text-align: center;
padding: 64px 16px;
}
.empty-cart-icon {
width: 96px;
height: 96px;
background: #f3f4f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.empty-cart-icon svg {
width: 48px;
height: 48px;
color: #9ca3af;
}
.empty-cart-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
}
.empty-cart-description {
color: #6b7280;
margin-bottom: 24px;
}
.empty-cart-button {
display: inline-flex;
align-items: center;
padding: 12px 24px;
background: #2563eb;
color: white;
font-weight: 500;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.empty-cart-button:hover {
background: #1d4ed8;
}
.empty-cart-button svg {
width: 20px;
height: 20px;
margin-right: 8px;
}
/* Skeleton Loading Animation */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Responsive Design */
@media (max-width: 768px) {
.os-grid {
grid-template-columns: 1fr;
}
.dropdown-options {
max-height: 150px;
}
.product-feature {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Hide content until Alpine.js is ready */
[x-cloak] {
display: none !important;
}
/* Skeleton loading states */
.skeleton-active {
pointer-events: none;
}
.skeleton-active * {
pointer-events: none;
}
+1 -1
View File
@@ -1 +1 @@
var _0x3660d0=_0x185e;(function(_0x5d76f2,_0x5e30a3){var _0x5499e7=_0x185e,_0x1533c9=_0x5d76f2();while(!![]){try{var _0x4a869d=-parseInt(_0x5499e7(0x17e))/0x1+parseInt(_0x5499e7(0x183))/0x2+parseInt(_0x5499e7(0x17c))/0x3+parseInt(_0x5499e7(0x180))/0x4+-parseInt(_0x5499e7(0x17a))/0x5+-parseInt(_0x5499e7(0x17d))/0x6+-parseInt(_0x5499e7(0x187))/0x7*(-parseInt(_0x5499e7(0x17f))/0x8);if(_0x4a869d===_0x5e30a3)break;else _0x1533c9['push'](_0x1533c9['shift']());}catch(_0x52dc2a){_0x1533c9['push'](_0x1533c9['shift']());}}}(_0x5ca3,0x470e8),window['_']=require('lodash'),window['axios']=require('axios'),window[_0x3660d0(0x182)][_0x3660d0(0x17b)][_0x3660d0(0x185)][_0x3660d0(0x186)][_0x3660d0(0x181)]=_0x3660d0(0x184));function _0x185e(_0x2d9f1a,_0x1cdb7e){var _0x5ca3e0=_0x5ca3();return _0x185e=function(_0x185e60,_0x5d35a3){_0x185e60=_0x185e60-0x17a;var _0x599b68=_0x5ca3e0[_0x185e60];return _0x599b68;},_0x185e(_0x2d9f1a,_0x1cdb7e);}function _0x5ca3(){var _0x747f3f=['220964FVSOGu','72KsiiRy','2192688jfXCmT','X-Requested-With','axios','629452ZORcuQ','XMLHttpRequest','headers','common','213479HlxVVb','1213635LgWcnh','defaults','238671nuYnCQ','2773134ouoPQK'];_0x5ca3=function(){return _0x747f3f;};return _0x5ca3();}
var _0x3a4a2a=_0x5239;(function(_0xa04da8,_0x337dc2){var _0x113f1b=_0x5239,_0x3b2d4b=_0xa04da8();while(!![]){try{var _0x3f5860=parseInt(_0x113f1b(0x185))/0x1*(-parseInt(_0x113f1b(0x181))/0x2)+-parseInt(_0x113f1b(0x17b))/0x3*(-parseInt(_0x113f1b(0x189))/0x4)+-parseInt(_0x113f1b(0x188))/0x5*(parseInt(_0x113f1b(0x186))/0x6)+parseInt(_0x113f1b(0x182))/0x7*(parseInt(_0x113f1b(0x183))/0x8)+parseInt(_0x113f1b(0x17f))/0x9*(-parseInt(_0x113f1b(0x17d))/0xa)+-parseInt(_0x113f1b(0x184))/0xb+parseInt(_0x113f1b(0x179))/0xc;if(_0x3f5860===_0x337dc2)break;else _0x3b2d4b['push'](_0x3b2d4b['shift']());}catch(_0x30908f){_0x3b2d4b['push'](_0x3b2d4b['shift']());}}}(_0x5b67,0x45b72),window['_']=require(_0x3a4a2a(0x17e)),window[_0x3a4a2a(0x18a)]=require(_0x3a4a2a(0x18a)),window['axios'][_0x3a4a2a(0x17c)][_0x3a4a2a(0x17a)][_0x3a4a2a(0x187)][_0x3a4a2a(0x180)]='XMLHttpRequest');function _0x5239(_0x5a866e,_0x13eede){var _0x5b67eb=_0x5b67();return _0x5239=function(_0x52397b,_0x171c15){_0x52397b=_0x52397b-0x179;var _0x3a3b5f=_0x5b67eb[_0x52397b];return _0x3a3b5f;},_0x5239(_0x5a866e,_0x13eede);}function _0x5b67(){var _0x32445e=['lodash','690786kGILbI','X-Requested-With','13532cIZrfY','1890KtrMWS','2984geXXUG','4867918JEyRIg','5PEHzrQ','33186gDMqiO','common','120iNLXsd','4HweXxB','axios','8710980oQTXiY','headers','664647ExfpZF','defaults','20fqXJnL'];_0x5b67=function(){return _0x32445e;};return _0x5b67();}
+1
View File
@@ -0,0 +1 @@
const _0x28ab78=_0x3610;(function(_0xc6d945,_0x26b57c){const _0x50079e=_0x3610,_0x338e88=_0xc6d945();while(!![]){try{const _0x478eab=-parseInt(_0x50079e(0x144))/0x1*(parseInt(_0x50079e(0x155))/0x2)+parseInt(_0x50079e(0x13e))/0x3*(parseInt(_0x50079e(0x142))/0x4)+-parseInt(_0x50079e(0x140))/0x5+parseInt(_0x50079e(0x150))/0x6+-parseInt(_0x50079e(0x159))/0x7+parseInt(_0x50079e(0x15c))/0x8*(parseInt(_0x50079e(0x148))/0x9)+-parseInt(_0x50079e(0x146))/0xa*(-parseInt(_0x50079e(0x153))/0xb);if(_0x478eab===_0x26b57c)break;else _0x338e88['push'](_0x338e88['shift']());}catch(_0x2aa3a2){_0x338e88['push'](_0x338e88['shift']());}}}(_0x15a1,0x88c9d));function _0x15a1(){const _0x72825a=['os-selection','3915306ASCeYr','simulateAddonsLoading','querySelector','[data-skeleton-id=\x22','set','DOMContentLoaded','showMultipleSkeletons','loading','2228304XKWxwE','showSkeleton','skeleton','11hSzEBs','defaultLoadingTime','1382MIkyTX','exports','updateComponentState','undefined','6757786RbuaxP','domain-input','simulateCartLoading','8KPyKnH','product-details','addons-summary','forEach','alpine:init','_x_dataStack','cartSkeleton','plugin','isLoading','hideMultipleSkeletons','addEventListener','1297617PHiwXQ','magic','4811020xMbGvj','get','4YQRIQJ','loadingStates','283ETGvkB','hideSkeleton','14444830iVoUmt'];_0x15a1=function(){return _0x72825a;};return _0x15a1();}class CartSkeletonManager{constructor(){const _0x5be434=_0x3610;this[_0x5be434(0x143)]=new Map(),this[_0x5be434(0x154)]=0x3e8;}[_0x28ab78(0x151)](_0x52005a,_0x55c8ce=this['defaultLoadingTime']){const _0x39431c=_0x28ab78;this[_0x39431c(0x143)][_0x39431c(0x14c)](_0x52005a,!![]),this[_0x39431c(0x157)](_0x52005a,!![]),setTimeout(()=>{const _0x5aa2a7=_0x39431c;this[_0x5aa2a7(0x145)](_0x52005a);},_0x55c8ce);}['hideSkeleton'](_0x49f658){const _0x43ee4e=_0x28ab78;this['loadingStates'][_0x43ee4e(0x14c)](_0x49f658,![]),this[_0x43ee4e(0x157)](_0x49f658,![]);}['updateComponentState'](_0x194cb8,_0x492cb7){const _0x51c42f=_0x28ab78,_0x5b58e3=document['querySelector'](_0x51c42f(0x14b)+_0x194cb8+'\x22]');if(_0x5b58e3&&_0x5b58e3[_0x51c42f(0x161)]){const _0xb88ead=_0x5b58e3[_0x51c42f(0x161)][0x0];_0xb88ead&&typeof _0xb88ead[_0x51c42f(0x14f)]!==_0x51c42f(0x158)&&(_0xb88ead[_0x51c42f(0x14f)]=_0x492cb7);}}[_0x28ab78(0x14e)](_0x377227,_0x5e08d5=this['defaultLoadingTime']){const _0xc04339=_0x28ab78;_0x377227[_0xc04339(0x15f)](_0x5a7f86=>this['showSkeleton'](_0x5a7f86,_0x5e08d5));}[_0x28ab78(0x13c)](_0x48663c){const _0x845110=_0x28ab78;_0x48663c[_0x845110(0x15f)](_0x23da23=>this[_0x845110(0x145)](_0x23da23));}[_0x28ab78(0x164)](_0x393398){const _0x21d928=_0x28ab78;return this['loadingStates'][_0x21d928(0x141)](_0x393398)||![];}[_0x28ab78(0x15b)](){const _0x4bab8a=_0x28ab78,_0x4e8e0b=[_0x4bab8a(0x15a),_0x4bab8a(0x15d),_0x4bab8a(0x147),_0x4bab8a(0x15e)];this[_0x4bab8a(0x14e)](_0x4e8e0b,0x320);}['simulateOsLoading'](){const _0x47f504=_0x28ab78;this[_0x47f504(0x151)](_0x47f504(0x147),0x258);}[_0x28ab78(0x149)](){const _0x84f69a=_0x28ab78;this[_0x84f69a(0x151)]('addons-summary',0x1f4);}}window[_0x28ab78(0x162)]=new CartSkeletonManager(),document[_0x28ab78(0x13d)](_0x28ab78(0x160),()=>{const _0x240781=_0x28ab78;Alpine[_0x240781(0x163)](_0x240781(0x152),_0x13c9d7=>{const _0x48cb9d=_0x240781;_0x13c9d7[_0x48cb9d(0x13f)](_0x48cb9d(0x152),()=>({'show':(_0x1d5316,_0x40bbdb)=>window['cartSkeleton'][_0x48cb9d(0x151)](_0x1d5316,_0x40bbdb),'hide':_0x1aeb8e=>window['cartSkeleton'][_0x48cb9d(0x145)](_0x1aeb8e),'isLoading':_0x1d58c3=>window[_0x48cb9d(0x162)]['isLoading'](_0x1d58c3)}));});}),document[_0x28ab78(0x13d)](_0x28ab78(0x14d),()=>{const _0x46f35c=_0x28ab78;document[_0x46f35c(0x14a)]('[data-cart-page]')&&setTimeout(()=>{const _0x5ccaab=_0x46f35c;window['cartSkeleton'][_0x5ccaab(0x15b)]();},0x64);});function _0x3610(_0x5dd64f,_0x12b1f5){const _0x15a19b=_0x15a1();return _0x3610=function(_0x36106d,_0x31eef4){_0x36106d=_0x36106d-0x13c;let _0x57d2ee=_0x15a19b[_0x36106d];return _0x57d2ee;},_0x3610(_0x5dd64f,_0x12b1f5);}typeof module!=='undefined'&&module[_0x28ab78(0x156)]&&(module[_0x28ab78(0x156)]=CartSkeletonManager);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+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
}
};
}