From d8bbaeeacc17202d49d090fb72f09e1a64d052bb Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Mon, 29 Jan 2024 15:19:07 +0000 Subject: [PATCH] Feat - Variant Generation (#1488) This PR looks to add variant generation to the filament hub. This isn't a port of the current offering in `0.7` but is instead a complete reimagining of how it should work, inspired by some other popular systems out there. --- packages/admin/resources/css/index.css | 2 +- packages/admin/resources/dist/lunar-panel.css | 2058 ++++++++++++++++- packages/admin/resources/dist/lunar-panel.js | 1 + .../admin/resources/lang/en/components.php | 21 + packages/admin/resources/lang/en/product.php | 71 - .../admin/resources/lang/en/productoption.php | 88 + .../resources/lang/en/productvariant.php | 102 + packages/admin/resources/lang/en/widgets.php | 11 + .../views/actions/switch-variant.blade.php | 5 + .../product-option-list-values.blade.php | 92 + .../variants/product-options-list.blade.php | 98 + .../forms/components/media-select.blade.php | 18 + .../widgets/product-options.blade.php | 229 ++ .../Products/MapVariantsToProductOptions.php | 82 + .../Filament/Resources/ProductResource.php | 12 + .../Pages/ManageProductIdentifiers.php | 19 +- .../Pages/ManageProductInventory.php | 49 +- .../Pages/ManageProductShipping.php | 40 +- .../Pages/ManageProductVariants.php | 9 + .../Widgets/ProductOptionsWidget.php | 460 ++++ .../Resources/ProductVariantResource.php | 316 +++ .../Pages/EditProductVariant.php | 78 + .../Pages/ListProductVariants.php | 26 + .../Pages/ManageVariantIdentifiers.php | 85 + .../Pages/ManageVariantInventory.php | 85 + .../Pages/ManageVariantMedia.php | 122 + .../Pages/ManageVariantPricing.php | 158 ++ .../Pages/ManageVariantShipping.php | 135 ++ .../Widgets/Products/VariantSwitcherTable.php | 102 + packages/admin/src/LunarPanelManager.php | 1 + .../Support/Forms/Components/MediaSelect.php | 15 + ...100010_update_product_option_relations.php | 2 +- ...100000_update_product_option_handle_fk.php | 28 + packages/core/src/LunarServiceProvider.php | 6 + packages/core/src/Models/ProductVariant.php | 2 + .../src/Observers/ProductOptionObserver.php | 18 + .../src/Observers/ProductVariantObserver.php | 20 + .../MapVariantsToProductOptionsTest.php | 79 + 38 files changed, 4585 insertions(+), 160 deletions(-) create mode 100644 packages/admin/resources/lang/en/productvariant.php create mode 100644 packages/admin/resources/views/actions/switch-variant.blade.php create mode 100644 packages/admin/resources/views/components/products/variants/product-option-list-values.blade.php create mode 100644 packages/admin/resources/views/components/products/variants/product-options-list.blade.php create mode 100644 packages/admin/resources/views/forms/components/media-select.blade.php create mode 100644 packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php create mode 100644 packages/admin/src/Actions/Products/MapVariantsToProductOptions.php create mode 100644 packages/admin/src/Filament/Resources/ProductResource/Widgets/ProductOptionsWidget.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/EditProductVariant.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ListProductVariants.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantIdentifiers.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantInventory.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantMedia.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantPricing.php create mode 100644 packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantShipping.php create mode 100644 packages/admin/src/Filament/Widgets/Products/VariantSwitcherTable.php create mode 100644 packages/admin/src/Support/Forms/Components/MediaSelect.php create mode 100644 packages/core/database/migrations/2024_01_24_100000_update_product_option_handle_fk.php create mode 100644 packages/core/src/Observers/ProductOptionObserver.php create mode 100644 packages/core/src/Observers/ProductVariantObserver.php create mode 100644 tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php diff --git a/packages/admin/resources/css/index.css b/packages/admin/resources/css/index.css index 29244ec365..b3949d56cd 100644 --- a/packages/admin/resources/css/index.css +++ b/packages/admin/resources/css/index.css @@ -1 +1 @@ -@import '../../vendor/filament/filament/resources/css/theme.css'; \ No newline at end of file +@import '../../vendor/filament/filament/resources/css/theme.css'; diff --git a/packages/admin/resources/dist/lunar-panel.css b/packages/admin/resources/dist/lunar-panel.css index e606e06e68..c9914e7c44 100644 --- a/packages/admin/resources/dist/lunar-panel.css +++ b/packages/admin/resources/dist/lunar-panel.css @@ -1 +1,2057 @@ -/*! tailwindcss v3.4.0 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:rgba(var(--gray-200),1)}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family),ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:rgba(var(--gray-400),1)}input::placeholder,textarea::placeholder{opacity:1;color:rgba(var(--gray-400),1)}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:rgba(var(--gray-500),var(--tw-border-opacity,1));border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:rgba(var(--gray-500),var(--tw-text-opacity,1));opacity:1}input::placeholder,textarea::placeholder{color:rgba(var(--gray-500),var(--tw-text-opacity,1));opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='rgba(var(--gray-500), var(--tw-stroke-opacity, 1))' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:rgba(var(--gray-500),var(--tw-border-opacity,1));border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:#0000;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}@media (forced-colors:active){[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active){[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:#0000;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active){[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:#0000;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root.dark{color-scheme:dark}[data-field-wrapper]{scroll-margin-top:8rem}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.-left-\[calc\(0\.5rem_-_1px\)\]{left:calc(-.5rem - -1px)}.-left-\[calc\(0\.75rem_-_1px\)\]{left:calc(-.75rem - -1px)}.left-0{left:0}.left-5{left:1.25rem}.right-0{right:0}.top-\[2px\]{top:2px}.-my-8{margin-top:-2rem;margin-bottom:-2rem}.-ml-\[5px\]{margin-left:-5px}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-5{margin-left:1.25rem}.ml-8{margin-left:2rem}.mt-4{margin-top:1rem}.flow-root{display:flow-root}.w-1\/3{width:33.333333%}.w-\[2px\]{width:2px}.min-w-\[50vw\]{min-width:50vw}.min-w-full{min-width:100%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.-translate-y-2{--tw-translate-y:-0.5rem}.-translate-y-2,.-translate-y-px{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-px{--tw-translate-y:-1px}.\!cursor-default{cursor:default!important}.cursor-grab{cursor:grab}.scroll-mt-32{scroll-margin-top:8rem}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.gap-0{gap:0}.gap-0\.5{gap:.125rem}.gap-2\.5{gap:.625rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.divide-y-2>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(2px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(2px*var(--tw-divide-y-reverse))}.\!overflow-auto{overflow:auto!important}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.border-green-300{--tw-border-opacity:1;border-color:rgb(134 239 172/var(--tw-border-opacity))}.border-orange-300{--tw-border-opacity:1;border-color:rgb(253 186 116/var(--tw-border-opacity))}.border-sky-300{--tw-border-opacity:1;border-color:rgb(125 211 252/var(--tw-border-opacity))}.border-white\/10{border-color:#ffffff1a}.bg-gray-300\/20{background-color:rgba(var(--gray-300),.2)}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity))}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity))}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity))}.bg-sky-500{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity))}.bg-teal-500{--tw-bg-opacity:1;background-color:rgb(20 184 166/var(--tw-bg-opacity))}.bg-white\/70{background-color:#ffffffb3}.\!p-0{padding:0!important}.\!p-3{padding:.75rem!important}.\!ps-6{padding-inline-start:1.5rem!important}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pt-8{padding-top:2rem}.pt-\[1px\]{padding-top:1px}.pt-\[5px\]{padding-top:5px}.\!text-red-400\/60{color:#f8717199!important}.\!text-red-400\/80{color:#f87171cc!important}.text-gray-900{--tw-text-opacity:1;color:rgba(var(--gray-900),var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-sky-600{--tw-text-opacity:1;color:rgb(2 132 199/var(--tw-text-opacity))}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring-1,.ring-4{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-gray-100{--tw-ring-opacity:1;--tw-ring-color:rgba(var(--gray-100),var(--tw-ring-opacity))}.ring-purple-100{--tw-ring-opacity:1;--tw-ring-color:rgb(243 232 255/var(--tw-ring-opacity))}.ring-sky-100{--tw-ring-opacity:1;--tw-ring-color:rgb(224 242 254/var(--tw-ring-opacity))}.ring-teal-100{--tw-ring-opacity:1;--tw-ring-color:rgb(204 251 241/var(--tw-ring-opacity))}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-danger-100\/80:hover{background-color:rgba(var(--danger-100),.8)}.hover\:bg-primary-400\/10:hover{background-color:rgba(var(--primary-400),.1)}.hover\:bg-primary-50:hover{--tw-bg-opacity:1;background-color:rgba(var(--primary-50),var(--tw-bg-opacity))}.hover\:bg-primary-50\/50:hover{background-color:rgba(var(--primary-50),.5)}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.group:hover .group-hover\:flex{display:flex}.group:hover .group-hover\:scale-110{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/button:hover .group-hover\/button\:text-gray-500{--tw-text-opacity:1;color:rgba(var(--gray-500),var(--tw-text-opacity))}.group:hover .group-hover\:text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity))}.group:hover .group-hover\:text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}@media (min-width:768px){.md\:min-w-\[32rem\]{min-width:32rem}.md\:justify-between{justify-content:space-between}}:is(:where([dir=ltr]) .ltr\:rotate-90){--tw-rotate:90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is(:where([dir=rtl]) .rtl\:\!rotate-90){--tw-rotate:90deg!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}:is(:where([dir=rtl]) .rtl\:rotate-180){--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is(:where([dir=rtl]) .rtl\:space-x-reverse)>:not([hidden])~:not([hidden]){--tw-space-x-reverse:1}:is(:where([dir=rtl]) .rtl\:text-right){text-align:right}:is(:where(.dark) .dark\:divide-gray-600)>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgba(var(--gray-600),var(--tw-divide-opacity))}:is(:where(.dark) .dark\:divide-white\/10)>:not([hidden])~:not([hidden]){border-color:#ffffff1a}:is(:where(.dark) .dark\:border-b){border-bottom-width:1px}:is(:where(.dark) .dark\:border-gray-600){--tw-border-opacity:1;border-color:rgba(var(--gray-600),var(--tw-border-opacity))}:is(:where(.dark) .dark\:border-white\/10){border-color:#ffffff1a}:is(:where(.dark) .dark\:bg-custom-400\/10){background-color:rgba(var(--c-400),.1)}:is(:where(.dark) .dark\:bg-gray-400\/10){background-color:rgba(var(--gray-400),.1)}:is(:where(.dark) .dark\:bg-gray-600){--tw-bg-opacity:1;background-color:rgba(var(--gray-600),var(--tw-bg-opacity))}:is(:where(.dark) .dark\:bg-gray-800){--tw-bg-opacity:1;background-color:rgba(var(--gray-800),var(--tw-bg-opacity))}:is(:where(.dark) .dark\:bg-gray-900){--tw-bg-opacity:1;background-color:rgba(var(--gray-900),var(--tw-bg-opacity))}:is(:where(.dark) .dark\:bg-green-400\/10){background-color:#4ade801a}:is(:where(.dark) .dark\:bg-orange-400\/10){background-color:#fb923c1a}:is(:where(.dark) .dark\:bg-sky-400\/10){background-color:#38bdf81a}:is(:where(.dark) .dark\:bg-white\/10){background-color:#ffffff1a}:is(:where(.dark) .dark\:bg-white\/5){background-color:#ffffff0d}:is(:where(.dark) .dark\:\!text-red-400\/60){color:#f8717199!important}:is(:where(.dark) .dark\:text-custom-400){--tw-text-opacity:1;color:rgba(var(--c-400),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-gray-100){--tw-text-opacity:1;color:rgba(var(--gray-100),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-gray-200){--tw-text-opacity:1;color:rgba(var(--gray-200),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-gray-300){--tw-text-opacity:1;color:rgba(var(--gray-300),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-gray-400){--tw-text-opacity:1;color:rgba(var(--gray-400),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-gray-500){--tw-text-opacity:1;color:rgba(var(--gray-500),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-green-400){--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-green-400\/80){color:#4ade80cc}:is(:where(.dark) .dark\:text-orange-400){--tw-text-opacity:1;color:rgb(251 146 60/var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-primary-400){--tw-text-opacity:1;color:rgba(var(--primary-400),var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-primary-400\/80){color:rgba(var(--primary-400),.8)}:is(:where(.dark) .dark\:text-red-400\/80){color:#f87171cc}:is(:where(.dark) .dark\:text-sky-400){--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity))}:is(:where(.dark) .dark\:text-white){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}:is(:where(.dark) .dark\:ring-custom-400\/30){--tw-ring-color:rgba(var(--c-400),0.3)}:is(:where(.dark) .dark\:ring-gray-400\/20){--tw-ring-color:rgba(var(--gray-400),0.2)}:is(:where(.dark) .dark\:ring-gray-600){--tw-ring-opacity:1;--tw-ring-color:rgba(var(--gray-600),var(--tw-ring-opacity))}:is(:where(.dark) .dark\:ring-gray-700){--tw-ring-opacity:1;--tw-ring-color:rgba(var(--gray-700),var(--tw-ring-opacity))}:is(:where(.dark) .dark\:ring-purple-800){--tw-ring-opacity:1;--tw-ring-color:rgb(107 33 168/var(--tw-ring-opacity))}:is(:where(.dark) .dark\:ring-sky-800){--tw-ring-opacity:1;--tw-ring-color:rgb(7 89 133/var(--tw-ring-opacity))}:is(:where(.dark) .dark\:ring-teal-800){--tw-ring-opacity:1;--tw-ring-color:rgb(17 94 89/var(--tw-ring-opacity))}:is(:where(.dark) .dark\:ring-white\/10){--tw-ring-color:#ffffff1a}:is(:where(.dark) .dark\:ring-white\/20){--tw-ring-color:#fff3}:is(:where(.dark) .dark\:hover\:bg-danger-300\/20:hover){background-color:rgba(var(--danger-300),.2)}:is(:where(.dark) .dark\:hover\:bg-white\/5:hover){background-color:#ffffff0d}:is(:where(.dark) .dark\:focus-visible\:ring-primary-500:focus-visible){--tw-ring-opacity:1;--tw-ring-color:rgba(var(--primary-500),var(--tw-ring-opacity))}:is(:where(.dark) .group\/button:hover .dark\:group-hover\/button\:text-gray-400){--tw-text-opacity:1;color:rgba(var(--gray-400),var(--tw-text-opacity))}:is(:where(.dark) .group:hover .dark\:group-hover\:text-green-400){--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity))}:is(:where(.dark) .group:hover .dark\:group-hover\:text-red-400){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.\[\&_table\]\:h-\[1px\] table{height:1px} \ No newline at end of file +/* +! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: rgba(var(--gray-200), 1); + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: var(--font-family), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: rgba(var(--gray-400), 1); + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: rgba(var(--gray-400), 1); + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: rgba(var(--gray-500), var(--tw-border-opacity, 1)); + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: rgba(var(--gray-500), var(--tw-text-opacity, 1)); + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: rgba(var(--gray-500), var(--tw-text-opacity, 1)); + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; + text-align: inherit; +} + +::-webkit-datetime-edit { + display: inline-flex; +} + +::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; +} + +select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='rgba(var(--gray-500)%2c var(--tw-stroke-opacity%2c 1))' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple],[size]:where(select:not([size="1"])) { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: rgba(var(--gray-500), var(--tw-border-opacity, 1)); + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +@media (forced-colors: active) { + [type='checkbox']:checked { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +[type='radio']:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); +} + +@media (forced-colors: active) { + [type='radio']:checked { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +@media (forced-colors: active) { + [type='checkbox']:indeterminate { + -webkit-appearance: auto; + -moz-appearance: auto; + appearance: auto; + } +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +:root.dark { + color-scheme: dark; +} + +/* When scrolling to validation error, do not hide element behind the top bar */ + +[data-field-wrapper] { + scroll-margin-top: 8rem; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.pointer-events-none { + pointer-events: none; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.inset-y-0 { + top: 0px; + bottom: 0px; +} + +.-left-\[calc\(0\.5rem_-_1px\)\] { + left: calc(calc(0.5rem - 1px) * -1); +} + +.-left-\[calc\(0\.75rem_-_1px\)\] { + left: calc(calc(0.75rem - 1px) * -1); +} + +.bottom-0 { + bottom: 0px; +} + +.left-0 { + left: 0px; +} + +.left-5 { + left: 1.25rem; +} + +.right-0 { + right: 0px; +} + +.top-\[2px\] { + top: 2px; +} + +.z-10 { + z-index: 10; +} + +.z-20 { + z-index: 20; +} + +.-my-8 { + margin-top: -2rem; + margin-bottom: -2rem; +} + +.mx-1 { + margin-left: 0.25rem; + margin-right: 0.25rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.-ml-\[5px\] { + margin-left: -5px; +} + +.-mt-3 { + margin-top: -0.75rem; +} + +.-mt-3\.5 { + margin-top: -0.875rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-4 { + margin-left: 1rem; +} + +.ml-5 { + margin-left: 1.25rem; +} + +.ml-7 { + margin-left: 1.75rem; +} + +.ml-8 { + margin-left: 2rem; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-0\.5 { + margin-top: 0.125rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.table { + display: table; +} + +.flow-root { + display: flow-root; +} + +.grid { + display: grid; +} + +.hidden { + display: none; +} + +.h-10 { + height: 2.5rem; +} + +.h-3 { + height: 0.75rem; +} + +.h-3\.5 { + height: 0.875rem; +} + +.h-4 { + height: 1rem; +} + +.h-5 { + height: 1.25rem; +} + +.h-6 { + height: 1.5rem; +} + +.h-8 { + height: 2rem; +} + +.h-full { + height: 100%; +} + +.w-1\/3 { + width: 33.333333%; +} + +.w-10 { + width: 2.5rem; +} + +.w-3 { + width: 0.75rem; +} + +.w-3\.5 { + width: 0.875rem; +} + +.w-32 { + width: 8rem; +} + +.w-4 { + width: 1rem; +} + +.w-5 { + width: 1.25rem; +} + +.w-6 { + width: 1.5rem; +} + +.w-8 { + width: 2rem; +} + +.w-\[2px\] { + width: 2px; +} + +.w-full { + width: 100%; +} + +.min-w-\[50vw\] { + min-width: 50vw; +} + +.min-w-full { + min-width: 100%; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.shrink-0 { + flex-shrink: 0; +} + +.grow { + flex-grow: 1; +} + +.table-auto { + table-layout: auto; +} + +.-translate-y-px { + --tw-translate-y: -1px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.\!cursor-default { + cursor: default !important; +} + +.cursor-default { + cursor: default; +} + +.cursor-grab { + cursor: grab; +} + +.scroll-mt-32 { + scroll-margin-top: 8rem; +} + +.appearance-none { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-end { + align-items: flex-end; +} + +.items-center { + align-items: center; +} + +.justify-start { + justify-content: flex-start; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-0 { + gap: 0px; +} + +.gap-0\.5 { + gap: 0.125rem; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-2\.5 { + gap: 0.625rem; +} + +.gap-3 { + gap: 0.75rem; +} + +.gap-4 { + gap: 1rem; +} + +.gap-6 { + gap: 1.5rem; +} + +.gap-x-3 { + -moz-column-gap: 0.75rem; + column-gap: 0.75rem; +} + +.gap-y-1 { + row-gap: 0.25rem; +} + +.space-x-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1rem * var(--tw-space-x-reverse)); + margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-y-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); +} + +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + +.space-y-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); +} + +.divide-x > :not([hidden]) ~ :not([hidden]) { + --tw-divide-x-reverse: 0; + border-right-width: calc(1px * var(--tw-divide-x-reverse)); + border-left-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); +} + +.divide-y > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); +} + +.divide-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(2px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(2px * var(--tw-divide-y-reverse)); +} + +.divide-gray-200 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgba(var(--gray-200), var(--tw-divide-opacity)); +} + +.divide-gray-950\/10 > :not([hidden]) ~ :not([hidden]) { + border-color: rgba(var(--gray-950), 0.1); +} + +.justify-self-end { + justify-self: end; +} + +.\!overflow-auto { + overflow: auto !important; +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-x-auto { + overflow-x: auto; +} + +.whitespace-normal { + white-space: normal; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-b-lg { + border-bottom-right-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; +} + +.border { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + +.border-x-\[0\.5px\] { + border-left-width: 0.5px; + border-right-width: 0.5px; +} + +.border-t { + border-top-width: 1px; +} + +.border-gray-100 { + --tw-border-opacity: 1; + border-color: rgba(var(--gray-100), var(--tw-border-opacity)); +} + +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgba(var(--gray-200), var(--tw-border-opacity)); +} + +.border-gray-600 { + --tw-border-opacity: 1; + border-color: rgba(var(--gray-600), var(--tw-border-opacity)); +} + +.border-green-300 { + --tw-border-opacity: 1; + border-color: rgb(134 239 172 / var(--tw-border-opacity)); +} + +.border-orange-300 { + --tw-border-opacity: 1; + border-color: rgb(253 186 116 / var(--tw-border-opacity)); +} + +.border-sky-300 { + --tw-border-opacity: 1; + border-color: rgb(125 211 252 / var(--tw-border-opacity)); +} + +.border-white\/10 { + border-color: rgb(255 255 255 / 0.1); +} + +.border-blue-500 { + --tw-border-opacity: 1; + border-color: rgb(59 130 246 / var(--tw-border-opacity)); +} + +.border-primary-500 { + --tw-border-opacity: 1; + border-color: rgba(var(--primary-500), var(--tw-border-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-200), var(--tw-bg-opacity)); +} + +.bg-gray-300 { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-300), var(--tw-bg-opacity)); +} + +.bg-gray-300\/20 { + background-color: rgba(var(--gray-300), 0.2); +} + +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-50), var(--tw-bg-opacity)); +} + +.bg-green-50 { + --tw-bg-opacity: 1; + background-color: rgb(240 253 244 / var(--tw-bg-opacity)); +} + +.bg-orange-50 { + --tw-bg-opacity: 1; + background-color: rgb(255 247 237 / var(--tw-bg-opacity)); +} + +.bg-purple-500 { + --tw-bg-opacity: 1; + background-color: rgb(168 85 247 / var(--tw-bg-opacity)); +} + +.bg-sky-50 { + --tw-bg-opacity: 1; + background-color: rgb(240 249 255 / var(--tw-bg-opacity)); +} + +.bg-sky-500 { + --tw-bg-opacity: 1; + background-color: rgb(14 165 233 / var(--tw-bg-opacity)); +} + +.bg-teal-500 { + --tw-bg-opacity: 1; + background-color: rgb(20 184 166 / var(--tw-bg-opacity)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-white\/70 { + background-color: rgb(255 255 255 / 0.7); +} + +.bg-custom-600 { + --tw-bg-opacity: 1; + background-color: rgba(var(--c-600), var(--tw-bg-opacity)); +} + +.\!p-0 { + padding: 0px !important; +} + +.\!p-3 { + padding: 0.75rem !important; +} + +.p-0 { + padding: 0px; +} + +.p-0\.5 { + padding: 0.125rem; +} + +.p-1 { + padding: 0.25rem; +} + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + +.px-1\.5 { + padding-left: 0.375rem; + padding-right: 0.375rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-3\.5 { + padding-top: 0.875rem; + padding-bottom: 0.875rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.\!ps-6 { + padding-inline-start: 1.5rem !important; +} + +.pb-4 { + padding-bottom: 1rem; +} + +.pe-2 { + padding-inline-end: 0.5rem; +} + +.pl-2 { + padding-left: 0.5rem; +} + +.pl-8 { + padding-left: 2rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.pt-8 { + padding-top: 2rem; +} + +.pt-\[1px\] { + padding-top: 1px; +} + +.pt-\[5px\] { + padding-top: 5px; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-bold { + font-weight: 700; +} + +.font-medium { + font-weight: 500; +} + +.font-normal { + font-weight: 400; +} + +.font-semibold { + font-weight: 600; +} + +.leading-6 { + line-height: 1.5rem; +} + +.\!text-red-400\/60 { + color: rgb(248 113 113 / 0.6) !important; +} + +.\!text-red-400\/80 { + color: rgb(248 113 113 / 0.8) !important; +} + +.text-gray-200 { + --tw-text-opacity: 1; + color: rgba(var(--gray-200), var(--tw-text-opacity)); +} + +.text-gray-400 { + --tw-text-opacity: 1; + color: rgba(var(--gray-400), var(--tw-text-opacity)); +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgba(var(--gray-500), var(--tw-text-opacity)); +} + +.text-gray-600 { + --tw-text-opacity: 1; + color: rgba(var(--gray-600), var(--tw-text-opacity)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgba(var(--gray-700), var(--tw-text-opacity)); +} + +.text-gray-900 { + --tw-text-opacity: 1; + color: rgba(var(--gray-900), var(--tw-text-opacity)); +} + +.text-gray-950 { + --tw-text-opacity: 1; + color: rgba(var(--gray-950), var(--tw-text-opacity)); +} + +.text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.text-orange-500 { + --tw-text-opacity: 1; + color: rgb(249 115 22 / var(--tw-text-opacity)); +} + +.text-orange-600 { + --tw-text-opacity: 1; + color: rgb(234 88 12 / var(--tw-text-opacity)); +} + +.text-primary-500 { + --tw-text-opacity: 1; + color: rgba(var(--primary-500), var(--tw-text-opacity)); +} + +.text-primary-600 { + --tw-text-opacity: 1; + color: rgba(var(--primary-600), var(--tw-text-opacity)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +.text-sky-600 { + --tw-text-opacity: 1; + color: rgb(2 132 199 / var(--tw-text-opacity)); +} + +.opacity-0 { + opacity: 0; +} + +.opacity-50 { + opacity: 0.5; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-sm { + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.outline-none { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-4 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-inset { + --tw-ring-inset: inset; +} + +.ring-gray-100 { + --tw-ring-opacity: 1; + --tw-ring-color: rgba(var(--gray-100), var(--tw-ring-opacity)); +} + +.ring-gray-200 { + --tw-ring-opacity: 1; + --tw-ring-color: rgba(var(--gray-200), var(--tw-ring-opacity)); +} + +.ring-gray-950\/10 { + --tw-ring-color: rgba(var(--gray-950), 0.1); +} + +.ring-gray-950\/5 { + --tw-ring-color: rgba(var(--gray-950), 0.05); +} + +.ring-purple-100 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(243 232 255 / var(--tw-ring-opacity)); +} + +.ring-sky-100 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(224 242 254 / var(--tw-ring-opacity)); +} + +.ring-teal-100 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(204 251 241 / var(--tw-ring-opacity)); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-200 { + transition-duration: 200ms; +} + +.duration-75 { + transition-duration: 75ms; +} + +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +.first\:border-s-0:first-child { + border-inline-start-width: 0px; +} + +.last\:border-e-0:last-child { + border-inline-end-width: 0px; +} + +.hover\:cursor-pointer:hover { + cursor: pointer; +} + +.hover\:bg-danger-100\/80:hover { + background-color: rgba(var(--danger-100), 0.8); +} + +.hover\:bg-gray-50:hover { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-50), var(--tw-bg-opacity)); +} + +.hover\:bg-primary-50:hover { + --tw-bg-opacity: 1; + background-color: rgba(var(--primary-50), var(--tw-bg-opacity)); +} + +.hover\:bg-primary-50\/50:hover { + background-color: rgba(var(--primary-50), 0.5); +} + +.hover\:text-gray-500:hover { + --tw-text-opacity: 1; + color: rgba(var(--gray-500), var(--tw-text-opacity)); +} + +.hover\:underline:hover { + text-decoration-line: underline; +} + +.focus-visible\:z-10:focus-visible { + z-index: 10; +} + +.focus-visible\:ring-2:focus-visible { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus-visible\:ring-primary-600:focus-visible { + --tw-ring-opacity: 1; + --tw-ring-color: rgba(var(--primary-600), var(--tw-ring-opacity)); +} + +.group\/item:first-child .group-first\/item\:rounded-s-lg { + border-start-start-radius: 0.5rem; + border-end-start-radius: 0.5rem; +} + +.group\/item:last-child .group-last\/item\:rounded-e-lg { + border-start-end-radius: 0.5rem; + border-end-end-radius: 0.5rem; +} + +.group:hover .group-hover\:flex { + display: flex; +} + +.group:hover .group-hover\:scale-110 { + --tw-scale-x: 1.1; + --tw-scale-y: 1.1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.group\/button:hover .group-hover\/button\:text-gray-500 { + --tw-text-opacity: 1; + color: rgba(var(--gray-500), var(--tw-text-opacity)); +} + +.group:hover .group-hover\:text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.group:hover .group-hover\:text-red-600 { + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:divide-gray-600) > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgba(var(--gray-600), var(--tw-divide-opacity)); +} + +:is(.dark .dark\:divide-white\/10) > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(255 255 255 / 0.1); +} + +:is(.dark .dark\:divide-white\/5) > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(255 255 255 / 0.05); +} + +:is(.dark .dark\:border-b) { + border-bottom-width: 1px; +} + +:is(.dark .dark\:border-gray-600) { + --tw-border-opacity: 1; + border-color: rgba(var(--gray-600), var(--tw-border-opacity)); +} + +:is(.dark .dark\:border-white\/10) { + border-color: rgb(255 255 255 / 0.1); +} + +:is(.dark .dark\:border-t-white\/10) { + border-top-color: rgb(255 255 255 / 0.1); +} + +:is(.dark .dark\:bg-gray-600) { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-600), var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-gray-800) { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-800), var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-gray-900) { + --tw-bg-opacity: 1; + background-color: rgba(var(--gray-900), var(--tw-bg-opacity)); +} + +:is(.dark .dark\:bg-green-400\/10) { + background-color: rgb(74 222 128 / 0.1); +} + +:is(.dark .dark\:bg-orange-400\/10) { + background-color: rgb(251 146 60 / 0.1); +} + +:is(.dark .dark\:bg-sky-400\/10) { + background-color: rgb(56 189 248 / 0.1); +} + +:is(.dark .dark\:bg-white\/10) { + background-color: rgb(255 255 255 / 0.1); +} + +:is(.dark .dark\:bg-white\/5) { + background-color: rgb(255 255 255 / 0.05); +} + +:is(.dark .dark\:\!text-red-400\/60) { + color: rgb(248 113 113 / 0.6) !important; +} + +:is(.dark .dark\:text-gray-100) { + --tw-text-opacity: 1; + color: rgba(var(--gray-100), var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-gray-200) { + --tw-text-opacity: 1; + color: rgba(var(--gray-200), var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-gray-300) { + --tw-text-opacity: 1; + color: rgba(var(--gray-300), var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-gray-400) { + --tw-text-opacity: 1; + color: rgba(var(--gray-400), var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-gray-500) { + --tw-text-opacity: 1; + color: rgba(var(--gray-500), var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-green-400) { + --tw-text-opacity: 1; + color: rgb(74 222 128 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-green-400\/80) { + color: rgb(74 222 128 / 0.8); +} + +:is(.dark .dark\:text-orange-400) { + --tw-text-opacity: 1; + color: rgb(251 146 60 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-primary-400) { + --tw-text-opacity: 1; + color: rgba(var(--primary-400), var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-primary-400\/80) { + color: rgba(var(--primary-400), 0.8); +} + +:is(.dark .dark\:text-red-400\/80) { + color: rgb(248 113 113 / 0.8); +} + +:is(.dark .dark\:text-sky-400) { + --tw-text-opacity: 1; + color: rgb(56 189 248 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-white) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:ring-gray-600) { + --tw-ring-opacity: 1; + --tw-ring-color: rgba(var(--gray-600), var(--tw-ring-opacity)); +} + +:is(.dark .dark\:ring-gray-700) { + --tw-ring-opacity: 1; + --tw-ring-color: rgba(var(--gray-700), var(--tw-ring-opacity)); +} + +:is(.dark .dark\:ring-purple-800) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(107 33 168 / var(--tw-ring-opacity)); +} + +:is(.dark .dark\:ring-sky-800) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(7 89 133 / var(--tw-ring-opacity)); +} + +:is(.dark .dark\:ring-teal-800) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(17 94 89 / var(--tw-ring-opacity)); +} + +:is(.dark .dark\:ring-white\/10) { + --tw-ring-color: rgb(255 255 255 / 0.1); +} + +:is(.dark .dark\:ring-white\/20) { + --tw-ring-color: rgb(255 255 255 / 0.2); +} + +:is(.dark .dark\:hover\:bg-danger-300\/20:hover) { + background-color: rgba(var(--danger-300), 0.2); +} + +:is(.dark .dark\:hover\:bg-white\/5:hover) { + background-color: rgb(255 255 255 / 0.05); +} + +:is(.dark .dark\:focus-visible\:ring-primary-500:focus-visible) { + --tw-ring-opacity: 1; + --tw-ring-color: rgba(var(--primary-500), var(--tw-ring-opacity)); +} + +:is(.dark .group\/button:hover .dark\:group-hover\/button\:text-gray-400) { + --tw-text-opacity: 1; + color: rgba(var(--gray-400), var(--tw-text-opacity)); +} + +:is(.dark .group:hover .dark\:group-hover\:text-green-400) { + --tw-text-opacity: 1; + color: rgb(74 222 128 / var(--tw-text-opacity)); +} + +:is(.dark .group:hover .dark\:group-hover\:text-red-400) { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + +@media (min-width: 640px) { + .sm\:ms-auto { + margin-inline-start: auto; + } + + .sm\:flex-row { + flex-direction: row; + } + + .sm\:items-center { + align-items: center; + } + + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .sm\:first-of-type\:ps-6:first-of-type { + padding-inline-start: 1.5rem; + } + + .sm\:last-of-type\:pe-6:last-of-type { + padding-inline-end: 1.5rem; + } +} + +@media (min-width: 768px) { + .md\:min-w-\[32rem\] { + min-width: 32rem; + } +} + +.ltr\:rotate-90:where([dir="ltr"], [dir="ltr"] *) { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.rtl\:\!rotate-90:where([dir="rtl"], [dir="rtl"] *) { + --tw-rotate: 90deg !important; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important; +} + +.rtl\:rotate-180:where([dir="rtl"], [dir="rtl"] *) { + --tw-rotate: 180deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.rtl\:space-x-reverse:where([dir="rtl"], [dir="rtl"] *) > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 1; +} + +.rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) { + text-align: right; +} + +.\[\&_table\]\:h-\[1px\] table { + height: 1px; +} diff --git a/packages/admin/resources/dist/lunar-panel.js b/packages/admin/resources/dist/lunar-panel.js index e69de29bb2..38d4cc9aab 100644 --- a/packages/admin/resources/dist/lunar-panel.js +++ b/packages/admin/resources/dist/lunar-panel.js @@ -0,0 +1 @@ +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFtdLAogICJzb3VyY2VzQ29udGVudCI6IFtdLAogICJtYXBwaW5ncyI6ICIiLAogICJuYW1lcyI6IFtdCn0K diff --git a/packages/admin/resources/lang/en/components.php b/packages/admin/resources/lang/en/components.php index 4ad2ba1e23..28295b696d 100644 --- a/packages/admin/resources/lang/en/components.php +++ b/packages/admin/resources/lang/en/components.php @@ -93,4 +93,25 @@ ], ], ], + + 'product-options-list' => [ + 'add-option' => [ + 'label' => 'Add Option', + ], + 'delete-option' => [ + 'label' => 'Delete Option', + ], + 'remove-shared-option' => [ + 'label' => 'Remove Shared Option', + ], + 'add-value' => [ + 'label' => 'Add Another Value', + ], + 'name' => [ + 'label' => 'Name', + ], + 'values' => [ + 'label' => 'Values', + ], + ], ]; diff --git a/packages/admin/resources/lang/en/product.php b/packages/admin/resources/lang/en/product.php index 52e87ac94c..2b36e83e8a 100644 --- a/packages/admin/resources/lang/en/product.php +++ b/packages/admin/resources/lang/en/product.php @@ -87,83 +87,12 @@ ], 'identifiers' => [ 'label' => 'Product Identifiers', - 'form' => [ - 'sku' => [ - 'label' => 'SKU', - ], - 'gtin' => [ - 'label' => 'Global Trade Item Number (GTIN)', - ], - 'mpn' => [ - 'label' => 'Manufacturer Part Number (MPN)', - ], - 'ean' => [ - 'label' => 'UPC/EAN', - ], - ], ], 'inventory' => [ 'label' => 'Inventory', - 'form' => [ - 'stock' => [ - 'label' => 'In Stock', - ], - 'backorder' => [ - 'label' => 'On Backorder', - ], - 'purchasable' => [ - 'label' => 'Purchasability', - 'options' => [ - 'always' => 'Always', - 'in_stock' => 'In Stock', - 'backorder' => 'Backorder Only', - ], - ], - 'unit_quantity' => [ - 'label' => 'Unit Quantity', - 'helper_text' => 'How many individual items make up 1 unit.', - ], - 'min_quantity' => [ - 'label' => 'Minimum Quantity', - 'helper_text' => 'The minimum quantity of a product variant that can be bought in a single purchase.', - ], - 'quantity_increment' => [ - 'label' => 'Quantity Increment', - 'helper_text' => 'The product variant must be purchased in multiples of this quantity.', - ], - ], ], 'shipping' => [ 'label' => 'Shipping', - 'form' => [ - 'shippable' => [ - 'label' => 'Shippable', - ], - 'length_value' => [ - 'label' => 'Length', - ], - 'length_unit' => [ - 'label' => 'Length Unit', - ], - 'width_value' => [ - 'label' => 'Width', - ], - 'width_unit' => [ - 'label' => 'Width Unit', - ], - 'height_value' => [ - 'label' => 'Height', - ], - 'height_unit' => [ - 'label' => 'Height Unit', - ], - 'weight_value' => [ - 'label' => 'Weight', - ], - 'weight_unit' => [ - 'label' => 'Weight Unit', - ], - ], ], ], diff --git a/packages/admin/resources/lang/en/productoption.php b/packages/admin/resources/lang/en/productoption.php index ddb2d3c384..7d4d9c799e 100644 --- a/packages/admin/resources/lang/en/productoption.php +++ b/packages/admin/resources/lang/en/productoption.php @@ -30,4 +30,92 @@ ], ], + 'widgets' => [ + 'product-options' => [ + 'notifications' => [ + 'save-variants' => [ + 'success' => [ + 'title' => 'Product Variants Saved', + ], + ], + ], + 'actions' => [ + 'cancel' => [ + 'label' => 'Cancel', + ], + 'save-options' => [ + 'label' => 'Save Options', + ], + 'add-shared-option' => [ + 'label' => 'Add Shared Option', + 'form' => [ + 'product_option' => [ + 'label' => 'Product Option', + ], + 'no_shared_components' => [ + 'label' => 'No shared options are available.', + ], + ], + ], + 'add-restricted-option' => [ + 'label' => 'Add Option', + ], + ], + 'options-list' => [ + 'empty' => [ + 'heading' => 'There are no product options configured', + 'description' => 'Add a shared or restricted product option to start generating some variants.', + ], + ], + 'options-table' => [ + 'title' => 'Product Options', + 'configure-options' => [ + 'label' => 'Configure Options', + ], + 'table' => [ + 'option' => [ + 'label' => 'Option', + ], + 'values' => [ + 'label' => 'Values', + ], + ], + ], + 'variants-table' => [ + 'title' => 'Product Variants', + 'actions' => [ + 'create' => [ + 'label' => 'Create Variant', + ], + 'edit' => [ + 'label' => 'Edit', + ], + 'delete' => [ + 'label' => 'Delete', + ], + ], + 'empty' => [ + 'heading' => 'No Variants Configured', + ], + 'table' => [ + 'new' => [ + 'label' => 'NEW', + ], + 'option' => [ + 'label' => 'Option', + ], + 'sku' => [ + 'label' => 'SKU', + ], + 'price' => [ + 'label' => 'Price', + ], + 'stock' => [ + 'label' => 'Stock', + ], + ], + ], + ], + ], + ]; diff --git a/packages/admin/resources/lang/en/productvariant.php b/packages/admin/resources/lang/en/productvariant.php new file mode 100644 index 0000000000..116ecda864 --- /dev/null +++ b/packages/admin/resources/lang/en/productvariant.php @@ -0,0 +1,102 @@ + 'Product Variant', + 'plural_label' => 'Product Variants', + 'pages' => [ + 'edit' => [ + 'title' => 'Basic Information', + ], + 'media' => [ + 'title' => 'Media', + 'form' => [ + 'no_selection' => [ + 'label' => 'You do not currently have an image selected for this variant.', + ], + 'no_media_available' => [ + 'label' => 'There is currently no media available on this product.', + ], + 'images' => [ + 'label' => 'Primary Image', + 'helper_text' => 'Select the product image which represents this variant.', + ], + ], + ], + 'identifiers' => [ + 'title' => 'Identifiers', + ], + 'inventory' => [ + 'title' => 'Inventory', + ], + 'shipping' => [ + 'title' => 'Shipping', + ], + ], + 'form' => [ + 'sku' => [ + 'label' => 'SKU', + ], + 'gtin' => [ + 'label' => 'Global Trade Item Number (GTIN)', + ], + 'mpn' => [ + 'label' => 'Manufacturer Part Number (MPN)', + ], + 'ean' => [ + 'label' => 'UPC/EAN', + ], + 'stock' => [ + 'label' => 'In Stock', + ], + 'backorder' => [ + 'label' => 'On Backorder', + ], + 'purchasable' => [ + 'label' => 'Purchasability', + 'options' => [ + 'always' => 'Always', + 'in_stock' => 'In Stock', + 'backorder' => 'Backorder Only', + ], + ], + 'unit_quantity' => [ + 'label' => 'Unit Quantity', + 'helper_text' => 'How many individual items make up 1 unit.', + ], + 'min_quantity' => [ + 'label' => 'Minimum Quantity', + 'helper_text' => 'The minimum quantity of a product variant that can be bought in a single purchase.', + ], + 'quantity_increment' => [ + 'label' => 'Quantity Increment', + 'helper_text' => 'The product variant must be purchased in multiples of this quantity.', + ], + 'shippable' => [ + 'label' => 'Shippable', + ], + 'length_value' => [ + 'label' => 'Length', + ], + 'length_unit' => [ + 'label' => 'Length Unit', + ], + 'width_value' => [ + 'label' => 'Width', + ], + 'width_unit' => [ + 'label' => 'Width Unit', + ], + 'height_value' => [ + 'label' => 'Height', + ], + 'height_unit' => [ + 'label' => 'Height Unit', + ], + 'weight_value' => [ + 'label' => 'Weight', + ], + 'weight_unit' => [ + 'label' => 'Weight Unit', + ], + ], +]; diff --git a/packages/admin/resources/lang/en/widgets.php b/packages/admin/resources/lang/en/widgets.php index 91be2b13ec..71e042746f 100644 --- a/packages/admin/resources/lang/en/widgets.php +++ b/packages/admin/resources/lang/en/widgets.php @@ -104,4 +104,15 @@ ], ], ], + 'variant_switcher' => [ + 'label' => 'Switch Variant', + 'table' => [ + 'sku' => [ + 'label' => 'SKU', + ], + 'values' => [ + 'label' => 'Values', + ], + ], + ], ]; diff --git a/packages/admin/resources/views/actions/switch-variant.blade.php b/packages/admin/resources/views/actions/switch-variant.blade.php new file mode 100644 index 0000000000..4a1e60346e --- /dev/null +++ b/packages/admin/resources/views/actions/switch-variant.blade.php @@ -0,0 +1,5 @@ +
+ @livewire(\Lunar\Admin\Filament\Widgets\Products\VariantSwitcherTable::class, [ + 'record' => $record, + ]) +
\ No newline at end of file diff --git a/packages/admin/resources/views/components/products/variants/product-option-list-values.blade.php b/packages/admin/resources/views/components/products/variants/product-option-list-values.blade.php new file mode 100644 index 0000000000..d2f1ecec74 --- /dev/null +++ b/packages/admin/resources/views/components/products/variants/product-option-list-values.blade.php @@ -0,0 +1,92 @@ +@props(['items', 'statePath', 'key', 'canAddValues', 'readonly' => false]) +
+
+ @foreach($items as $itemIndex => $valueItem) +
+
+ @if(!$readonly) +
!$readonly, + 'text-gray-200' => $readonly, + ]) + x-sortable-handle + > + +
+ @endif +
!$valueItem['enabled'] + ]) + > + + + +
+
+ @if(!$readonly) +
+ +
+ @else +
+ +
+ @endif +
+
+
+ @endforeach +
+ @if($canAddValues) +
+ + {{ __('lunarpanel::components.product-options-list.add-value.label') }} + +
+
+ @endif +
\ No newline at end of file diff --git a/packages/admin/resources/views/components/products/variants/product-options-list.blade.php b/packages/admin/resources/views/components/products/variants/product-options-list.blade.php new file mode 100644 index 0000000000..4cf4f74746 --- /dev/null +++ b/packages/admin/resources/views/components/products/variants/product-options-list.blade.php @@ -0,0 +1,98 @@ +@props(['items', 'group', 'statePath', 'context' => 'options', 'optionKey' => null]) +
+ @foreach($items as $itemIndex => $item) +
+
+
+
+ + {{ __('lunarpanel::components.product-options-list.name.label') }} + +
+
!$item['readonly'] || $context == 'options', + ' text-gray-200' => $item['readonly'] && $context == 'values', + ]) + @if(!$item['readonly'] || $context == 'options') x-sortable-option-handle @endif + > + +
+
+ + + + +
+
+
+
+
+ + {{ __('lunarpanel::components.product-options-list.values.label') }} + +
+ +
+
+
+
+ @endforeach + + + {{ __('lunarpanel::components.product-options-list.add-option.label') }} + +
\ No newline at end of file diff --git a/packages/admin/resources/views/forms/components/media-select.blade.php b/packages/admin/resources/views/forms/components/media-select.blade.php new file mode 100644 index 0000000000..7dd817cacf --- /dev/null +++ b/packages/admin/resources/views/forms/components/media-select.blade.php @@ -0,0 +1,18 @@ + +
+ @foreach($getOptions() as $value => $label) + + @endforeach +
+
\ No newline at end of file diff --git a/packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php b/packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php new file mode 100644 index 0000000000..c5e1c1db15 --- /dev/null +++ b/packages/admin/resources/views/resources/product-resource/widgets/product-options.blade.php @@ -0,0 +1,229 @@ + + + @if(!$this->configuringOptions) +
+
+
+
+

+ {{ __('lunarpanel::productoption.widgets.product-options.options-table.title') }} +

+
+
+ + {{ __('lunarpanel::productoption.widgets.product-options.options-table.configure-options.label') }} + +
+
+
+ @if(count($this->configuredOptions)) + + + + + + {{ __('lunarpanel::productoption.widgets.product-options.options-table.table.option.label') }} + + + + + {{ __('lunarpanel::productoption.widgets.product-options.options-table.table.values.label') }} + + + + + + @foreach($this->configuredOptions as $option) + + +
+ + {{ $option['value'] }} + +
+
+ +
+ + {{ collect($option['option_values']) + ->filter( + fn ($value) => $value['enabled'] + )->map( + fn ($value) => $value['value'] + )->join(', ') }} + +
+
+
+ @endforeach + +
+ @else + + @endif +
+
+ +
+
+
+

+ {{ __('lunarpanel::productoption.widgets.product-options.variants-table.title') }} +

+
+
+
+ @if(count($this->variants)) + + + + @if($this->hasNewVariants) + + + @endif + + + {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.option.label') }} + + + + + {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.sku.label') }} + + + + + {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.price.label') }} + + + + + {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.stock.label') }} + + + + + + + + + @foreach($this->variants as $permutationIndex => $permutation) + + @if($this->hasNewVariants) + +
+ @if(!$permutation['variant_id']) + + {{ __('lunarpanel::productoption.widgets.product-options.variants-table.table.new.label') }} + + @endif +
+
+ @endif + +
+ + @foreach($permutation['values'] as $option => $value) + {{ $option }}: {{ $value }} + @endforeach + +
+
+ +
+ + + +
+
+ +
+ + + +
+
+ +
+ + + +
+
+ +
+ @if($permutation['variant_id']) + + {{ __('lunarpanel::productoption.widgets.product-options.variants-table.actions.edit.label') }} + + @endif + +
+
+ +
+ @endforeach + +
+ @else + + @endif +
+
+
+ +
+ {{ $this->saveVariantsAction }} +
+ + @else +
+
+
+ + {{ __('lunarpanel::productoption.widgets.product-options.actions.add-restricted-option.label') }} + + {{ $this->addSharedOptionAction }} +
+
+ @if(!count($this->configuredOptions)) +
+ +
+ @else +
+ +
+ @endif + +
+ + {{ __('lunarpanel::productoption.widgets.product-options.actions.save-options.label') }} + + + {{ __('lunarpanel::productoption.widgets.product-options.actions.cancel.label') }} + +
+
+ + @endif +
diff --git a/packages/admin/src/Actions/Products/MapVariantsToProductOptions.php b/packages/admin/src/Actions/Products/MapVariantsToProductOptions.php new file mode 100644 index 0000000000..69bb4fbcb2 --- /dev/null +++ b/packages/admin/src/Actions/Products/MapVariantsToProductOptions.php @@ -0,0 +1,82 @@ + $p, + ]; + } + $permutations = $newPermutations; + } + + $variantPermutations = []; + + foreach ($permutations as $permutation) { + $variantIndex = collect($variants)->search(function ($variant) use ($permutation) { + $valueDifference = array_diff_assoc($permutation, $variant['values']); + + if (! count($valueDifference)) { + return $variant; + } + + $amountMatched = count($permutation) - count($valueDifference); + + return $amountMatched == count($variant['values']); + }); + + $variant = $variants[$variantIndex] ?? null; + + $variantId = $variant['id'] ?? null; + $sku = $variant['sku'] ?? null; + $copiedFrom = null; + $shouldFill = true; + + if ($variant) { + // Does this variant already exist in our permutations? + // if so we want to mark it as new but + $existing = collect($variantPermutations) + ->first( + fn ($p) => $p['variant_id'] == $variant['id'] + ); + + // Now what? + if ($existing) { + $diff = array_diff_assoc($permutation, $variant['values']); + $sku = $existing['sku'].'-'.implode('-', array_values($diff)); + $variantId = null; + $copiedFrom = $variant['id']; + } + + if ($existing && ! $fillMissing) { + $shouldFill = false; + } + } + + if ($shouldFill) { + $variantPermutations[] = [ + 'key' => Str::random(), + 'variant_id' => $variantId, + 'copied_id' => $copiedFrom, + 'sku' => $sku, + 'price' => $variant['price'] ?? 0, + 'stock' => $variant['stock'] ?? 0, + 'values' => $permutation, + ]; + } + } + + return $variantPermutations; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductResource.php b/packages/admin/src/Filament/Resources/ProductResource.php index e884be8afe..b6db375341 100644 --- a/packages/admin/src/Filament/Resources/ProductResource.php +++ b/packages/admin/src/Filament/Resources/ProductResource.php @@ -18,6 +18,8 @@ use Illuminate\Database\Eloquent\Model; use Lunar\Admin\Filament\Resources\ProductResource\Pages; use Lunar\Admin\Filament\Resources\ProductResource\RelationManagers\CustomerGroupRelationManager; +use Lunar\Admin\Filament\Resources\ProductResource\Widgets\ProductOptionsWidget; +use Lunar\Admin\Filament\Widgets\Products\VariantSwitcherTable; use Lunar\Admin\Support\Forms\Components\Attributes; use Lunar\Admin\Support\Forms\Components\Tags as TagsComponent; use Lunar\Admin\Support\RelationManagers\ChannelRelationManager; @@ -34,6 +36,8 @@ class ProductResource extends BaseResource protected static ?string $model = Product::class; + protected static ?string $recordTitleAttribute = 'recordTitle'; + protected static ?int $navigationSort = 1; protected static int $globalSearchResultsLimit = 5; @@ -77,6 +81,14 @@ public static function getRecordSubNavigation(Page $page): array ]); } + public static function getWidgets(): array + { + return [ + ProductOptionsWidget::class, + VariantSwitcherTable::class, + ]; + } + public static function getDefaultForm(Form $form): Form { return $form diff --git a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductIdentifiers.php b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductIdentifiers.php index 91499b384b..6e2215dbd3 100644 --- a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductIdentifiers.php +++ b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductIdentifiers.php @@ -3,12 +3,12 @@ namespace Lunar\Admin\Filament\Resources\ProductResource\Pages; use Filament\Forms\Components\Section; -use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Support\Facades\FilamentIcon; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Model; use Lunar\Admin\Filament\Resources\ProductResource; +use Lunar\Admin\Filament\Resources\ProductVariantResource; use Lunar\Admin\Support\Pages\BaseEditRecord; use Lunar\Models\ProductVariant; @@ -94,24 +94,15 @@ public function form(Form $form): Form return $form->schema([ Section::make()->schema([ - TextInput::make('sku') - ->label( - __('lunarpanel::product.pages.identifiers.form.sku.label') - ) + ProductVariantResource::getSkuFormComponent() ->live()->unique( table: fn () => $variant->getTable(), ignorable: $variant, ignoreRecord: true, ), - TextInput::make('gtin')->label( - __('lunarpanel::product.pages.identifiers.form.gtin.label') - ), - TextInput::make('mpn')->label( - __('lunarpanel::product.pages.identifiers.form.mpn.label') - ), - TextInput::make('ean')->label( - __('lunarpanel::product.pages.identifiers.form.ean.label') - ), + ProductVariantResource::getGtinFormComponent(), + ProductVariantResource::getMpnFormComponent(), + ProductVariantResource::getEanFormComponent(), ])->columns(1), ])->statePath(''); } diff --git a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductInventory.php b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductInventory.php index 405ab713a5..893a899b25 100644 --- a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductInventory.php +++ b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductInventory.php @@ -2,14 +2,12 @@ namespace Lunar\Admin\Filament\Resources\ProductResource\Pages; -use Filament\Forms\Components\Section; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Support\Facades\FilamentIcon; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Model; use Lunar\Admin\Filament\Resources\ProductResource; +use Lunar\Admin\Filament\Resources\ProductVariantResource\Pages\ManageVariantInventory; use Lunar\Admin\Support\Pages\BaseEditRecord; use Lunar\Models\ProductVariant; @@ -96,50 +94,7 @@ protected function getFormActions(): array public function form(Form $form): Form { - $variant = $this->getVariant(); - - return $form->schema([ - Section::make()->schema([ - TextInput::make('stock') - ->label( - __('lunarpanel::product.pages.inventory.form.stock.label') - )->numeric(), - TextInput::make('backorder') - ->label( - __('lunarpanel::product.pages.inventory.form.backorder.label') - )->numeric(), - Select::make('purchasable') - ->options([ - 'always' => __('lunarpanel::product.pages.inventory.form.purchasable.options.always'), - 'in_stock' => __('lunarpanel::product.pages.inventory.form.purchasable.options.in_stock'), - 'backorder' => __('lunarpanel::product.pages.inventory.form.purchasable.options.backorder'), - ]) - ->label( - __('lunarpanel::product.pages.inventory.form.purchasable.label') - ), - TextInput::make('unit_quantity') - ->label( - __('lunarpanel::product.pages.inventory.form.unit_quantity.label') - )->helperText( - __('lunarpanel::product.pages.inventory.form.unit_quantity.helper_text') - )->numeric(), - TextInput::make('quantity_increment') - ->label( - __('lunarpanel::product.pages.inventory.form.quantity_increment.label') - )->helperText( - __('lunarpanel::product.pages.inventory.form.quantity_increment.helper_text') - )->numeric(), - TextInput::make('min_quantity') - ->label( - __('lunarpanel::product.pages.inventory.form.min_quantity.label') - )->helperText( - __('lunarpanel::product.pages.inventory.form.min_quantity.helper_text') - )->numeric(), - ])->columns([ - 'sm' => 1, - 'xl' => 3, - ]), - ])->statePath(''); + return (new ManageVariantInventory())->form($form)->statePath(''); } public function getRelationManagers(): array diff --git a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductShipping.php b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductShipping.php index cbd5c20ad1..fcf9429219 100644 --- a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductShipping.php +++ b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductShipping.php @@ -11,6 +11,7 @@ use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Model; use Lunar\Admin\Filament\Resources\ProductResource; +use Lunar\Admin\Filament\Resources\ProductVariantResource\Pages\ManageVariantShipping; use Lunar\Admin\Support\Pages\BaseEditRecord; use Lunar\Models\ProductVariant; use Marvinosswald\FilamentInputSelectAffix\TextInputSelectAffix; @@ -83,7 +84,20 @@ protected function handleRecordUpdate(Model $record, array $data): Model ...[ 'shippable' => $this->shippable, 'volume_unit' => 'l', - 'volume_value' => $this->volume, + 'volume_value' => ManageVariantShipping::getVolume( + [ + 'value' => $this->dimensions['width_value'], + 'unit' => $this->dimensions['width_unit'], + ], + [ + 'value' => $this->dimensions['length_value'], + 'unit' => $this->dimensions['length_unit'], + ], + [ + 'value' => $this->dimensions['height_value'], + 'unit' => $this->dimensions['height_unit'], + ] + ), ], ...$this->dimensions, ]); @@ -103,30 +117,6 @@ protected function getFormActions(): array ]; } - public function getVolumeProperty() - { - $dimensions = $this->dimensions; - - $width = Converter::value($dimensions['width_value']) - ->from('length.'.$dimensions['width_unit']) - ->to('length.cm') - ->convert() - ->getValue(); - $length = Converter::value($dimensions['length_value']) - ->from('length.'.$dimensions['length_unit']) - ->to('length.cm') - ->convert() - ->getValue(); - - $height = Converter::value($dimensions['height_value']) - ->from('length.'.$dimensions['height_unit']) - ->to('length.cm') - ->convert() - ->getValue(); - - return Converter::from('volume.ml')->to('volume.l')->value($length * $width * $height)->convert()->getValue(); - } - public function form(Form $form): Form { $measurements = Converter::getMeasurements(); diff --git a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductVariants.php b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductVariants.php index 4c7e47b78e..79ef65c030 100644 --- a/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductVariants.php +++ b/packages/admin/src/Filament/Resources/ProductResource/Pages/ManageProductVariants.php @@ -18,6 +18,13 @@ class ManageProductVariants extends ManageRelatedRecords protected static ?string $title = 'Variants'; + protected function getHeaderWidgets(): array + { + return [ + ProductResource\Widgets\ProductOptionsWidget::class, + ]; + } + public static function getNavigationIcon(): ?string { return FilamentIcon::resolve('lunar::product-variants'); @@ -54,6 +61,8 @@ public function form(Form $form): Form public function table(Table $table): Table { + return $table; + return $table ->recordTitleAttribute('name') ->columns([ diff --git a/packages/admin/src/Filament/Resources/ProductResource/Widgets/ProductOptionsWidget.php b/packages/admin/src/Filament/Resources/ProductResource/Widgets/ProductOptionsWidget.php new file mode 100644 index 0000000000..a57ea1ce6f --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductResource/Widgets/ProductOptionsWidget.php @@ -0,0 +1,460 @@ +configureBaseOptions(); + } + + public function addSharedOptionAction() + { + $existing = collect($this->configuredOptions)->pluck('id'); + $options = ProductOption::whereNotIn('id', $existing) + ->shared() + ->get(); + + return Action::make('addSharedOption') + ->form([ + Shout::make('no_shared_components') + ->content( + __('lunarpanel::productoption.widgets.product-options.actions.add-shared-option.form.no_shared_components.label') + ) + ->visible( + $options->isEmpty() + ), + Select::make('product_option') + ->options( + fn () => $options->mapWithKeys( + fn ($option) => [$option->id => $option->translate('name')] + ) + )->label( + __('lunarpanel::productoption.widgets.product-options.actions.add-shared-option.form.product_option.label') + )->visible( + $options->isNotEmpty() + ), + ])->action(function (array $data) { + $productOption = ProductOption::with(['values'])->find($data['product_option']); + $this->configuredOptions[] = $this->mapOption( + $productOption, + $productOption->values->map( + fn ($value) => $this->mapOptionValue($value, true) + )->toArray() + ); + }); + } + + public function configureBaseOptions(): void + { + $productOptions = $this->query()->get(); + + $sharedOptionIds = $productOptions->filter( + fn ($option) => $option->shared + )->pluck('id'); + + $disabledSharedOptionValues = ProductOptionValue::whereIn( + 'product_option_id', + $sharedOptionIds + )->whereNotIn( + 'id', + $productOptions->pluck('values')->flatten()->pluck('id') + )->get(); + + $options = []; + + foreach ($productOptions as $productOption) { + $values = $productOption->values->map(function ($value) { + return $this->mapOptionValue($value, true); + })->merge( + $disabledSharedOptionValues->filter( + fn ($value) => $value->product_option_id == $productOption->id + )->map( + fn ($value) => $this->mapOptionValue($value, false) + ) + )->sortBy('position')->values()->toArray(); + + $options[] = $this->mapOption($productOption, $values); + } + + $this->configuredOptions = $options; + + $this->mapVariantPermutations(fillMissing: false); + } + + public function cancelOptionConfiguring(): void + { + $this->configuringOptions = false; + $this->configureBaseOptions(); + } + + public function query() + { + return $this->record->productOptions() + ->with('values', function ($query) { + $query->whereHas('variants', function ($relation) { + $relation->whereIn($relation->getModel()->getTable().'.id', $this->record->variants()->pluck('id')); + }); + }); + } + + public function addRestrictedOption() + { + $this->configuredOptions[] = [ + 'id' => null, + 'value' => '', + 'position' => count($this->configuredOptions) + 1, + 'readonly' => false, + 'option_values' => [ + [ + 'id' => null, + 'value' => '', + 'position' => 1, + 'enabled' => true, + ], + ], + ]; + } + + public function updateConfiguredOptions() + { + $this->validate([ + 'configuredOptions' => 'array', + 'configuredOptions.*.value' => 'required|string', + 'configuredOptions.*.option_values.*.value' => 'required|string', + ]); + + // Go through each one and if a configuration has none enabled, then just + // remove it from the array. + $options = collect(); + + foreach ($this->configuredOptions as $configuredOption) { + $enabledCount = collect($configuredOption['option_values']) + ->filter( + fn ($value) => $value['enabled'] + )->count(); + + if ($enabledCount) { + $options->push($configuredOption); + } + } + + $this->configuredOptions = $options->values()->toArray(); + + $this->mapVariantPermutations(); + + $this->configuringOptions = false; + } + + public function removeVariant($key): void + { + unset($this->variants[$key]); + } + + public function addOptionValue($path) + { + $option = $this->configuredOptions[$path]; + + if ($option['readonly']) { + return; + } + + $this->configuredOptions[$path]['option_values'][] = [ + 'value' => '', + 'position' => count($this->configuredOptions[$path]['option_values']) + 1, + 'readonly' => false, + 'enabled' => true, + ]; + } + + public function removeOptionValue($index, $valueIndex) + { + unset($this->configuredOptions[$index]['option_values'][$valueIndex]); + } + + public function removeOption($index) + { + $options = collect($this->configuredOptions)->forget($index); + $this->configuredOptions = $options->values()->toArray(); + } + + public function updateValuePositions($optionKey, $rows) + { + $this->configuredOptions[$optionKey]['option_values'] = $rows; + } + + public function updateOptionPositions($rows) + { + $this->configuredOptions = $rows; + } + + public function mapVariantPermutations($fillMissing = true): void + { + $optionValues = collect($this->configuredOptions) + ->filter( + fn ($option) => $option['value'] + ) + ->mapWithKeys( + fn ($option) => [$option['value'] => collect($option['option_values']) + ->filter( + fn ($value) => $value['enabled'] + ) + ->map( + fn ($value) => $value['value'] + )] + )->toArray(); + + $variants = $this->record->variants->load('values.option')->map(function ($variant) { + return [ + 'id' => $variant->id, + 'sku' => $variant->sku, + 'price' => $variant->basePrices->first()?->price->decimal ?: 0, + 'stock' => $variant->stock, + 'values' => $variant->values->mapWithKeys( + fn ($value) => [$value->option->translate('name') => $value->translate('name')] + )->toArray(), + ]; + })->toArray(); + + $this->variants = MapVariantsToProductOptions::map($optionValues, $variants, $fillMissing); + } + + public function getHasNewVariantsProperty() + { + return collect($this->variants) + ->reject( + fn ($variant) => $variant['variant_id'] + )->isNotEmpty(); + } + + protected function storeConfiguredOptions(): void + { + $language = Language::getDefault(); + /** + * Go through our configured options and if they don't + * exist in the database i.e. they are new, create and map them + * so they are ready. + */ + foreach ($this->configuredOptions as $optionIndex => $option) { + + $optionModel = empty($option['id']) ? + new ProductOption([ + 'shared' => false, + ]) : + ProductOption::find($option['id']); + + $optionValue = $option['value']; + + $optionModel->name = [ + $language->code => $optionValue, + ]; + $optionModel->label = [ + $language->code => $optionValue, + ]; + $optionModel->handle = Str::slug($optionValue); + $optionModel->save(); + + $this->configuredOptions[$optionIndex]['id'] = $optionModel->id; + $option['id'] = $optionModel->id; + + foreach ($option['option_values'] as $optionValueIndex => $value) { + $optionValueModel = empty($value['id']) ? + new ProductOptionValue([ + 'product_option_id' => $option['id'], + ]) : + ProductOptionValue::find($value['id']); + + $optionValueModel->name = [ + $language->code => $value['value'], + ]; + $optionValueModel->position = $value['position']; + $optionValueModel->save(); + + $this->configuredOptions[$optionIndex]['option_values'][$optionValueIndex]['id'] = + $optionValueModel->id; + } + } + } + + protected function mapOptionValuesToIds(array $values): array + { + $valueIds = []; + foreach ($values as $option => $value) { + $configuredOption = collect( + $this->configuredOptions + )->first( + fn ($o) => $o['value'] == $option + ); + + $valueId = collect($configuredOption['option_values'])->first( + fn ($v) => $v['value'] == $value + )['id']; + $valueIds[] = $valueId; + } + + return $valueIds; + } + + public function saveVariantsAction() + { + return Action::make('saveVariants') + ->action(function () { + DB::beginTransaction(); + + $this->storeConfiguredOptions(); + + /** + * If there are no variants, then all the configured option + * have been removed. In this case we still want to keep a + * variant at least one is needed for Lunar to function. + */ + if (! count($this->variants)) { + $variant = $this->record->variants()->first(); + $variant->values()->detach(); + $this->record->productOptions()->exclusive()->delete(); + $this->record->productOptions()->shared()->detach(); + $this->record->variants() + ->where('id', '!=', $variant->id) + ->get() + ->each( + fn ($variant) => $variant->delete() + ); + + DB::commit(); + + Notification::make()->title( + __('lunarpanel::productoption.widgets.product-options.notifications.save-variants.success.title') + )->success()->send(); + + return; + } + + foreach ($this->variants as $variantIndex => $variantData) { + $variant = new ProductVariant([ + 'product_id' => $this->record->id, + ]); + $basePrice = null; + + if (! empty($variantData['variant_id'])) { + $variant = ProductVariant::find($variantData['variant_id']); + $basePrice = $variant->basePrices->first(); + } + + if (! empty($variantData['copied_id'])) { + $copiedVariant = ProductVariant::find( + $variantData['copied_id'] + ); + + $variant = $copiedVariant->replicate(); + $variant->save(); + + $basePrice = $copiedVariant->basePrices->first()->replicate(); + $basePrice->priceable_id = $variant->id; + } + + $variant->sku = $variantData['sku']; + $variant->stock = $variantData['stock']; + $variant->save(); + + $basePrice->price = (int) bcmul($variantData['price'], $basePrice->currency->factor); + $basePrice->save(); + + $optionsValues = $this->mapOptionValuesToIds($variantData['values']); + + $variant->values()->sync($optionsValues); + + $this->variants[$variantIndex]['variant_id'] = $variant->id; + } + + $productOptions = collect($this->configuredOptions) + ->mapWithKeys(function ($option) { + return [ + $option['id'] => [ + 'position' => $option['position'], + ], + ]; + }); + + $this->record->productOptions()->sync($productOptions); + + $variantIds = collect($this->variants)->pluck('variant_id'); + + $this->record->variants()->whereNotIn('id', $variantIds) + ->get() + ->each( + fn ($variant) => $variant->delete() + ); + DB::commit(); + + Notification::make()->title( + __('lunarpanel::productoption.widgets.product-options.notifications.save-variants.success.title') + )->success()->send(); + }); + } + + public function getVariantLink($variantId) + { + return ProductVariantResource::getUrl('edit', [ + 'product' => $this->record, + 'record' => $variantId, + ]); + } + + protected function mapOptionValue(ProductOptionValue $value, bool $enabled = true) + { + return [ + 'id' => $value->id, + 'enabled' => $enabled, + 'value' => $value->translate('name'), + 'position' => $value->position, + ]; + } + + protected function mapOption(ProductOption $option, array $values = []): array + { + return [ + 'id' => $option->id, + 'key' => "option_{$option->id}", + 'value' => $option->translate('name'), + 'position' => $option->pivot?->position ?: count($this->configuredOptions) + 1, + 'readonly' => $option->shared, + 'option_values' => $values, + ]; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource.php b/packages/admin/src/Filament/Resources/ProductVariantResource.php new file mode 100644 index 0000000000..de29b30f15 --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource.php @@ -0,0 +1,316 @@ +generateNavigationItems([ + Pages\EditProductVariant::class, + Pages\ManageVariantMedia::class, + Pages\ManageVariantPricing::class, + Pages\ManageVariantIdentifiers::class, + Pages\ManageVariantInventory::class, + Pages\ManageVariantShipping::class, + ]); + } + + public static function getBaseBreadcrumbs(ProductVariant $productVariant): array + { + return [ + ProductResource::getUrl('edit', [ + 'record' => $productVariant->product, + ]) => $productVariant->product->attr('name'), + ProductResource::getUrl('variants', [ + 'record' => $productVariant->product, + ]) => 'Variants', + ProductVariantResource::getUrl('edit', [ + 'record' => $productVariant, + ]) => $productVariant->sku, + ]; + } + + public static function getDefaultForm(Form $form): Form + { + return $form + ->schema([ + static::getAttributeDataFormComponent(), + ]) + ->columns(1); + } + + protected static function getMainFormComponents(): array + { + return [ + static::getSkuFormComponent(), + ]; + } + + public static function getSkuFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('sku'); + } + + public static function getGtinFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('gtin')->label( + __('lunarpanel::productvariant.form.gtin.label') + ); + } + + public static function getMpnFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('mpn')->label( + __('lunarpanel::productvariant.form.mpn.label') + ); + } + + public static function getEanFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('ean')->label( + __('lunarpanel::productvariant.form.ean.label') + ); + } + + public static function getStockFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('stock') + ->label( + __('lunarpanel::productvariant.form.stock.label') + )->numeric(); + } + + public static function getBackorderFormComponent(): Forms\Components\TextInput + { + return + Forms\Components\TextInput::make('backorder') + ->label( + __('lunarpanel::productvariant.form.backorder.label') + )->numeric(); + } + + public static function getPurchasableFormComponent(): Forms\Components\Select + { + return Forms\Components\Select::make('purchasable') + ->options([ + 'always' => __('lunarpanel::productvariant.form.purchasable.options.always'), + 'in_stock' => __('lunarpanel::productvariant.form.purchasable.options.in_stock'), + 'backorder' => __('lunarpanel::productvariant.form.purchasable.options.backorder'), + ]) + ->label( + __('lunarpanel::productvariant.form.purchasable.label') + ); + } + + public static function getUnitQtyFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('unit_quantity') + ->label( + __('lunarpanel::productvariant.form.unit_quantity.label') + )->helperText( + __('lunarpanel::productvariant.form.unit_quantity.helper_text') + )->numeric(); + } + + public static function getQuantityIncrementFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('quantity_increment') + ->label( + __('lunarpanel::productvariant.form.quantity_increment.label') + )->helperText( + __('lunarpanel::productvariant.form.quantity_increment.helper_text') + )->numeric(); + } + + public static function getMinQuantityFormComponent(): Forms\Components\TextInput + { + return Forms\Components\TextInput::make('min_quantity') + ->label( + __('lunarpanel::productvariant.form.min_quantity.label') + )->helperText( + __('lunarpanel::productvariant.form.min_quantity.helper_text') + )->numeric(); + } + + public static function getShippableFormComponent(): Forms\Components\Toggle + { + return Forms\Components\Toggle::make('shippable')->label( + __('lunarpanel::productvariant.form.shippable.label') + )->columnSpan(2); + } + + public static function getMeasurements($key = null): array + { + $measurements = Converter::getMeasurements(); + + return collect( + array_keys($measurements[$key] ?? []) + )->mapWithKeys( + fn ($value) => [$value => $value] + )->toArray(); + } + + public static function getLengthFormComponent(): TextInputSelectAffix + { + return TextInputSelectAffix::make('length_value') + ->label( + __('lunarpanel::productvariant.form.length_value.label') + ) + ->numeric() + ->select( + fn () => Forms\Components\Select::make('length_unit') + ->options( + static::getMeasurements('length') + ) + ->label( + __('lunarpanel::productvariant.form.length_unit.label') + )->selectablePlaceholder(false) + ); + } + + public static function getWidthFormComponent(): TextInputSelectAffix + { + return TextInputSelectAffix::make('width_value') + ->label( + __('lunarpanel::productvariant.form.width_value.label') + ) + ->numeric() + ->select( + fn () => Forms\Components\Select::make('width_unit') + ->options( + static::getMeasurements('length') + ) + ->label( + __('lunarpanel::productvariant.form.width_unit.label') + )->selectablePlaceholder(false) + ); + } + + public static function getHeightFormComponent(): TextInputSelectAffix + { + return TextInputSelectAffix::make('height_value') + ->label( + __('lunarpanel::productvariant.form.height_value.label') + ) + ->numeric() + ->select( + fn () => Forms\Components\Select::make('height_unit') + ->options( + static::getMeasurements('length') + ) + ->label( + __('lunarpanel::productvariant.form.height_unit.label') + )->selectablePlaceholder(false) + ); + } + + public static function getWeightFormComponent(): TextInputSelectAffix + { + return TextInputSelectAffix::make('weight_value') + ->label( + __('lunarpanel::productvariant.form.weight_value.label') + ) + ->numeric() + ->select( + fn () => Forms\Components\Select::make('weight_unit') + ->options( + static::getMeasurements('weight') + ) + ->label( + __('lunarpanel::productvariant.form.weight_unit.label') + )->selectablePlaceholder(false) + ); + } + + public static function getVariantSwitcherWidget(Model $record): Action + { + return Action::make('switch_variant') + ->label( + __('lunarpanel::widgets.variant_switcher.label') + ) + ->modalContent(function () use ($record) { + return view('lunarpanel::actions.switch-variant', [ + 'record' => $record->product, + ]); + }) + ->slideOver(); + } + + protected static function getAttributeDataFormComponent(): Component + { + return Attributes::make()->statePath('attribute_data'); + } + + public static function getDefaultTable(Table $table): Table + { + return $table + ->columns(static::getTableColumns()) + ->filters([]) + ->actions([]) + ->bulkActions([]) + ->selectCurrentPageOnly() + ->deferLoading(); + } + + protected static function getTableColumns(): array + { + return [ + + ]; + } + + public static function getDefaultRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListProductVariants::route('/'), + 'edit' => Pages\EditProductVariant::route('/{record}/edit'), + 'pricing' => Pages\ManageVariantPricing::route('/{record}/pricing'), + 'media' => Pages\ManageVariantMedia::route('/{record}/media'), + 'identifiers' => Pages\ManageVariantIdentifiers::route('/{record}/identifiers'), + 'inventory' => Pages\ManageVariantInventory::route('/{record}/inventory'), + 'shipping' => Pages\ManageVariantShipping::route('/{record}/shipping'), + ]; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/EditProductVariant.php b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/EditProductVariant.php new file mode 100644 index 0000000000..d609ceb7f2 --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/EditProductVariant.php @@ -0,0 +1,78 @@ +getRecord() + ); + } + + protected function getCancelFormAction(): Action + { + return parent::getCancelFormAction()->url(function (Model $record) { + return ProductResource::getUrl('variants', [ + 'record' => $record->product, + ]); + }); + } + + public function mount(int|string $record): void + { + parent::mount($record); + + $variant = $this->getRecord(); + + if ($variant->mappedAttributes->isEmpty()) { + redirect()->to( + ProductVariantResource::getUrl('identifiers', [ + 'record' => $this->getRecord(), + ]) + ); + } + } + + public static function shouldRegisterNavigation(array $parameters = []): bool + { + return $parameters['record']->mappedAttributes->isNotEmpty(); + } + + protected function getDefaultHeaderActions(): array + { + return [ + ProductVariantResource::getVariantSwitcherWidget( + $this->getRecord() + ), + ]; + } + + public function getRelationManagers(): array + { + return []; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ListProductVariants.php b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ListProductVariants.php new file mode 100644 index 0000000000..85b7384c77 --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ListProductVariants.php @@ -0,0 +1,26 @@ +url(function (Model $record) { + return ProductResource::getUrl('variants', [ + 'record' => $record->product, + ]); + }); + } + + public function getBreadcrumbs(): array + { + return [ + ...ProductVariantResource::getBaseBreadcrumbs( + $this->getRecord() + ), + ProductVariantResource::getUrl('inventory', [ + 'record' => $this->getRecord(), + ]) => $this->getTitle(), + ]; + } + + public static function getNavigationIcon(): ?string + { + return FilamentIcon::resolve('lunar::product-identifiers'); + } + + public function form(Form $form): Form + { + return $form->schema([ + Section::make()->schema([ + ProductVariantResource::getSkuFormComponent() + ->live()->unique( + table: fn () => $this->getRecord()->getTable(), + ignorable: $this->getRecord(), + ignoreRecord: true, + ), + ProductVariantResource::getGtinFormComponent(), + ProductVariantResource::getMpnFormComponent(), + ProductVariantResource::getEanFormComponent(), + ])->columns(1), + ]); + } + + protected function getDefaultHeaderActions(): array + { + return [ + ProductVariantResource::getVariantSwitcherWidget( + $this->getRecord() + ), + ]; + } + + public function getRelationManagers(): array + { + return []; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantInventory.php b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantInventory.php new file mode 100644 index 0000000000..3dec22cadd --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantInventory.php @@ -0,0 +1,85 @@ +url(function (Model $record) { + return ProductResource::getUrl('variants', [ + 'record' => $record->product, + ]); + }); + } + + public function getBreadcrumbs(): array + { + return [ + ...ProductVariantResource::getBaseBreadcrumbs( + $this->getRecord() + ), + ProductVariantResource::getUrl('inventory', [ + 'record' => $this->getRecord(), + ]) => $this->getTitle(), + ]; + } + + public static function getNavigationIcon(): ?string + { + return FilamentIcon::resolve('lunar::product-inventory'); + } + + protected function getDefaultHeaderActions(): array + { + return [ + ProductVariantResource::getVariantSwitcherWidget( + $this->getRecord() + ), + ]; + } + + public function form(Form $form): Form + { + return $form->schema([ + Section::make()->schema([ + ProductVariantResource::getStockFormComponent(), + ProductVariantResource::getBackorderFormComponent(), + ProductVariantResource::getPurchasableFormComponent(), + ProductVariantResource::getUnitQtyFormComponent(), + ProductVariantResource::getQuantityIncrementFormComponent(), + ProductVariantResource::getMinQuantityFormComponent(), + ])->columns([ + 'sm' => 1, + 'xl' => 3, + ]), + ]); + } + + public function getRelationManagers(): array + { + return []; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantMedia.php b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantMedia.php new file mode 100644 index 0000000000..0828ab9d06 --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantMedia.php @@ -0,0 +1,122 @@ +getRecord() + ), + ]; + } + + protected function getCancelFormAction(): Action + { + return parent::getCancelFormAction()->url(function (Model $record) { + return ProductResource::getUrl('variants', [ + 'record' => $record->product, + ]); + }); + } + + public function getBreadcrumbs(): array + { + return [ + ...ProductVariantResource::getBaseBreadcrumbs( + $this->getRecord() + ), + ProductVariantResource::getUrl('media', [ + 'record' => $this->getRecord(), + ]) => $this->getTitle(), + ]; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $record->images()->sync([ + $data['images'] => ['primary' => true], + ]); + + return $record; + } + + public function form(Form $form): Form + { + return $form->schema([ + Section::make()->schema([ + Shout::make('no_selection')->content( + __('lunarpanel::productvariant.pages.media.form.no_selection.label') + )->visible( + fn (Get $get) => ! $get('images') && $this->getRecord()->product->media()->count() + ), + Shout::make('no_media_available')->content( + __('lunarpanel::productvariant.pages.media.form.no_media_available.label') + )->visible( + fn (Get $get) => ! $this->getRecord()->product->media()->count() + ), + MediaSelect::make('images') + ->visible( + fn () => $this->getRecord()->product->media()->count() + ) + ->label( + __('lunarpanel::productvariant.pages.media.form.images.label') + ) + ->helperText( + __('lunarpanel::productvariant.pages.media.form.images.helper_text') + ) + ->afterStateHydrated(function (ProductVariant $record, MediaSelect $component) { + $image = $record->images->first(function ($media) { + return (bool) $media->pivot?->primary; + }); + $component->state($image?->id); + }) + ->options( + $this->getRecord()->product->media->mapWithKeys( + fn ($media) => [ + $media->id => $media->getUrl('small'), + ] + ) + ), + ]), + ]); + } + + public function getRelationManagers(): array + { + return []; + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantPricing.php b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantPricing.php new file mode 100644 index 0000000000..ec7ce02ff3 --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantPricing.php @@ -0,0 +1,158 @@ +getRecord() + ), + ]; + } + + protected function getCancelFormAction(): Action + { + return parent::getCancelFormAction()->url(function (Model $record) { + return ProductResource::getUrl('variants', [ + 'record' => $record->product, + ]); + }); + } + + public function getBreadcrumbs(): array + { + return [ + ...ProductVariantResource::getBaseBreadcrumbs( + $this->getRecord() + ), + ProductVariantResource::getUrl('pricing', [ + 'record' => $this->getRecord(), + ]) => $this->getTitle(), + ]; + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\TextInput::make('price')->formatStateUsing( + fn ($state) => $state?->decimal(rounding: false) + )->numeric()->unique( + modifyRuleUsing: function (Unique $rule, Forms\Get $get) { + $owner = $this->getOwnerRecord(); + + return $rule->where('customer_group_id', $get('customer_group_id')) + ->where('tier', $get('tier')) + ->where('currency_id', $get('currency_id')) + ->where('priceable_type', get_class($owner)) + ->where('priceable_id', $owner->id); + } + )->required(), + Forms\Components\TextInput::make('tier') + ->label( + __('lunarpanel::relationmanagers.pricing.form.tier.label') + )->numeric()->minValue(1)->required(), + Forms\Components\Select::make('currency_id') + ->label( + __('lunarpanel::relationmanagers.pricing.form.currency_id.label') + )->relationship(name: 'currency', titleAttribute: 'name')->required(), + Forms\Components\Select::make('customer_group_id') + ->label( + __('lunarpanel::relationmanagers.pricing.form.customer_group_id.label') + )->relationship(name: 'customerGroup', titleAttribute: 'name'), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->modifyQueryUsing( + fn ($query) => $query->orderBy('tier', 'asc') + ) + ->columns([ + Tables\Columns\TextColumn::make('price') + ->label( + __('lunarpanel::relationmanagers.pricing.table.price.label') + )->formatStateUsing( + fn ($state) => $state->formatted, + ), + Tables\Columns\TextColumn::make('currency.code')->label( + __('lunarpanel::relationmanagers.pricing.table.currency.label') + ), + Tables\Columns\TextColumn::make('tier')->label( + __('lunarpanel::relationmanagers.pricing.table.tier.label') + ), + Tables\Columns\TextColumn::make('customerGroup.name')->label( + __('lunarpanel::relationmanagers.pricing.table.customer_group.label') + ), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('currency') + ->relationship(name: 'currency', titleAttribute: 'name') + ->preload(), + Tables\Filters\SelectFilter::make('tier')->options( + Price::where('priceable_id', $this->getOwnerRecord()->id) + ->where('priceable_type', get_class($this->getOwnerRecord())) + ->get() + ->pluck('tier', 'tier') + ), + ]) + ->headerActions([ + Tables\Actions\CreateAction::make()->mutateFormDataUsing(function (array $data) { + $currencyModel = Currency::find($data['currency_id']); + + $data['price'] = (int) ($data['price'] * $currencyModel->factor); + + return $data; + }), + ]) + ->actions([ + Tables\Actions\EditAction::make()->mutateFormDataUsing(function (array $data): array { + $currencyModel = Currency::find($data['currency_id']); + + $data['price'] = (int) ($data['price'] * $currencyModel->factor); + + return $data; + }), + Tables\Actions\DeleteAction::make(), + ]); + } +} diff --git a/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantShipping.php b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantShipping.php new file mode 100644 index 0000000000..b5fa30a954 --- /dev/null +++ b/packages/admin/src/Filament/Resources/ProductVariantResource/Pages/ManageVariantShipping.php @@ -0,0 +1,135 @@ +getRecord() + ), + ]; + } + + protected function getCancelFormAction(): Action + { + return parent::getCancelFormAction()->url(function (Model $record) { + return ProductResource::getUrl('variants', [ + 'record' => $record->product, + ]); + }); + } + + public function getBreadcrumbs(): array + { + return [ + ...ProductVariantResource::getBaseBreadcrumbs( + $this->getRecord() + ), + ProductVariantResource::getUrl('shipping', [ + 'record' => $this->getRecord(), + ]) => $this->getTitle(), + ]; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $volume = static::getVolume( + [ + 'value' => $data['width_value'], + 'unit' => $data['width_unit'] ?? $record->width_unit, + ], + [ + 'value' => $data['length_value'], + 'unit' => $data['length_unit'] ?? $record->length_unit, + ], + [ + 'value' => $data['height_value'], + 'unit' => $data['height_unit'] ?? $record->height_unit, + ] + ); + + $record->update([ + ...$data, + ...[ + 'volume_unit' => 'l', + 'volume_value' => $volume, + ], + ]); + + return $record; + } + + public static function getVolume($width = [], $length = [], $height = []) + { + $width = Converter::value($width['value']) + ->from('length.'.$width['unit']) + ->to('length.cm') + ->convert() + ->getValue(); + $length = Converter::value($length['value']) + ->from('length.'.$length['unit']) + ->to('length.cm') + ->convert() + ->getValue(); + + $height = Converter::value($height['value']) + ->from('length.'.$height['unit']) + ->to('length.cm') + ->convert() + ->getValue(); + + return Converter::from('volume.ml')->to('volume.l')->value($length * $width * $height)->convert()->getValue(); + } + + public function form(Form $form): Form + { + return $form->schema([ + Section::make()->schema([ + ProductVariantResource::getShippableFormComponent(), + ProductVariantResource::getLengthFormComponent(), + ProductVariantResource::getWidthFormComponent(), + ProductVariantResource::getHeightFormComponent(), + ProductVariantResource::getWeightFormComponent(), + ])->columns([ + 'sm' => 1, + 'xl' => 2, + ]), + ]); + } + + public function getRelationManagers(): array + { + return []; + } +} diff --git a/packages/admin/src/Filament/Widgets/Products/VariantSwitcherTable.php b/packages/admin/src/Filament/Widgets/Products/VariantSwitcherTable.php new file mode 100644 index 0000000000..bff6c1caf0 --- /dev/null +++ b/packages/admin/src/Filament/Widgets/Products/VariantSwitcherTable.php @@ -0,0 +1,102 @@ +record->id); + } + + protected function getTableFilters(): array + { + $optionValues = ProductOptionValue::whereHas( + 'variants', + fn ($query) => $query->whereIn( + 'variant_id', + $this->getTableQuery()->pluck('id') + )) + ->with(['option']) + ->get() + ->groupBy('product_option_id'); + + $filters = []; + + foreach ($optionValues as $values) { + $option = $values->first()->option; + + $filters[] = Tables\Filters\SelectFilter::make( + $option->handle + )->label($option->translate('name')) + ->options( + $values->mapWithKeys( + fn ($value) => [$value->id => $value->translate('name')] + ) + )->modifyQueryUsing(function (Builder $query, array $data) { + $value = $data['value']; + + return $query->when( + $value, + function ($query) use ($value) { + $query->whereHas('values', function ($relation) use ($value) { + $table = $relation->getQuery()->from; + + $relation->where("{$table}.id", '=', $value); + }); + } + ); + }); + } + + return $filters; + } + + protected function getTableColumns(): array + { + return [ + Tables\Columns\TextColumn::make('sku') + ->label( + __('lunarpanel::widgets.variant_switcher.table.sku.label') + )->searchable(), + Tables\Columns\TextColumn::make('values') + ->label( + __('lunarpanel::widgets.variant_switcher.table.values.label') + ) + ->formatStateUsing( + function (Model $record) { + return $record->values->map( + fn ($value) => $value->translate('name') + )->join(', '); + } + ), + ]; + } + + protected function getTableRecordUrlUsing(): ?Closure + { + return function (ProductVariant $variant) { + return ProductVariantResource::getUrl('edit', [ + 'record' => $variant, + ]); + }; + } + + protected function getTableHeading(): string|Htmlable|null + { + return ''; + } +} diff --git a/packages/admin/src/LunarPanelManager.php b/packages/admin/src/LunarPanelManager.php index bfb880fe68..abefae1135 100644 --- a/packages/admin/src/LunarPanelManager.php +++ b/packages/admin/src/LunarPanelManager.php @@ -56,6 +56,7 @@ class LunarPanelManager Resources\ProductOptionResource::class, Resources\ProductResource::class, Resources\ProductTypeResource::class, + Resources\ProductVariantResource::class, Resources\StaffResource::class, Resources\TagResource::class, Resources\TaxClassResource::class, diff --git a/packages/admin/src/Support/Forms/Components/MediaSelect.php b/packages/admin/src/Support/Forms/Components/MediaSelect.php new file mode 100644 index 0000000000..b62d70982a --- /dev/null +++ b/packages/admin/src/Support/Forms/Components/MediaSelect.php @@ -0,0 +1,15 @@ +groupBy(['product_id', 'product_option_id']) ->orderBy('product_id') - ->chunk(2, function ($rows) { + ->chunk(200, function ($rows) { DB::table( $this->prefix.'product_product_option' )->insert( diff --git a/packages/core/database/migrations/2024_01_24_100000_update_product_option_handle_fk.php b/packages/core/database/migrations/2024_01_24_100000_update_product_option_handle_fk.php new file mode 100644 index 0000000000..e0a5b2e6ec --- /dev/null +++ b/packages/core/database/migrations/2024_01_24_100000_update_product_option_handle_fk.php @@ -0,0 +1,28 @@ +prefix.'product_options', function (Blueprint $table) { + $table->dropUnique( + $this->prefix.'product_options_handle_unique' + ); + }); + + Schema::table($this->prefix.'product_options', function (Blueprint $table) { + $table->index('handle'); + }); + } + + public function down() + { + // .. + } +} diff --git a/packages/core/src/LunarServiceProvider.php b/packages/core/src/LunarServiceProvider.php index e74400fca4..c72f0f8f54 100644 --- a/packages/core/src/LunarServiceProvider.php +++ b/packages/core/src/LunarServiceProvider.php @@ -64,6 +64,8 @@ use Lunar\Models\Language; use Lunar\Models\Order; use Lunar\Models\OrderLine; +use Lunar\Models\ProductOption; +use Lunar\Models\ProductVariant; use Lunar\Models\Transaction; use Lunar\Models\Url; use Lunar\Observers\AddressObserver; @@ -75,6 +77,8 @@ use Lunar\Observers\LanguageObserver; use Lunar\Observers\OrderLineObserver; use Lunar\Observers\OrderObserver; +use Lunar\Observers\ProductOptionObserver; +use Lunar\Observers\ProductVariantObserver; use Lunar\Observers\TransactionObserver; use Lunar\Observers\UrlObserver; @@ -277,6 +281,8 @@ protected function registerObservers(): void Url::observe(UrlObserver::class); Collection::observe(CollectionObserver::class); CartLine::observe(CartLineObserver::class); + ProductOption::observe(ProductOptionObserver::class); + ProductVariant::observe(ProductVariantObserver::class); Order::observe(OrderObserver::class); OrderLine::observe(OrderLineObserver::class); Address::observe(AddressObserver::class); diff --git a/packages/core/src/Models/ProductVariant.php b/packages/core/src/Models/ProductVariant.php index 6759c1573e..74b4287e53 100644 --- a/packages/core/src/Models/ProductVariant.php +++ b/packages/core/src/Models/ProductVariant.php @@ -9,6 +9,7 @@ use Lunar\Base\BaseModel; use Lunar\Base\Casts\AsAttributeData; use Lunar\Base\Purchasable; +use Lunar\Base\Traits\HasAttributes; use Lunar\Base\Traits\HasDimensions; use Lunar\Base\Traits\HasMacros; use Lunar\Base\Traits\HasPrices; @@ -51,6 +52,7 @@ */ class ProductVariant extends BaseModel implements Purchasable { + use HasAttributes; use HasDimensions; use HasFactory; use HasMacros; diff --git a/packages/core/src/Observers/ProductOptionObserver.php b/packages/core/src/Observers/ProductOptionObserver.php new file mode 100644 index 0000000000..6708c74fb4 --- /dev/null +++ b/packages/core/src/Observers/ProductOptionObserver.php @@ -0,0 +1,18 @@ +values()->delete(); + } +} diff --git a/packages/core/src/Observers/ProductVariantObserver.php b/packages/core/src/Observers/ProductVariantObserver.php new file mode 100644 index 0000000000..5d58b8e347 --- /dev/null +++ b/packages/core/src/Observers/ProductVariantObserver.php @@ -0,0 +1,20 @@ +prices()->delete(); + $productVariant->values()->detach(); + $productVariant->images()->detach(); + } +} diff --git a/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php b/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php new file mode 100644 index 0000000000..66cda680e0 --- /dev/null +++ b/tests/admin/Unit/Actions/Products/MapVariantsToProductOptionsTest.php @@ -0,0 +1,79 @@ +group('support.actions'); + +it('can map variants given one set of option values', function () { + + $optionValues = [ + 'Shoe Size' => [ + 'UK-5', + 'UK-10', + 'UK-15', + ], + ]; + + $variants = [ + [ + 'id' => 1, + 'sku' => 'ABC', + 'values' => [ + 'Shoe Size' => 'UK-5', + ], + ], + [ + 'id' => 2, + 'sku' => 'DEF', + 'values' => [ + 'Shoe Size' => 'UK-10', + ], + ], + [ + 'id' => 3, + 'sku' => 'GHI', + 'values' => [ + 'Shoe Size' => 'UK-15', + ], + ], + ]; + + $result = MapVariantsToProductOptions::map($optionValues, $variants); + + expect($result[0]['sku'])->toBe('ABC'); + expect($result[1]['sku'])->toBe('DEF'); + expect($result[2]['sku'])->toBe('GHI'); +}); + +it('can map variants given three sets of option values', function () { + + $optionValues = [ + 'Size' => [ + 'Small', + 'Medium', + ], + 'Colour' => [ + 'Blue', + 'Black', + ], + 'Material' => [ + 'Black', + ], + ]; + + $variants = [ + [ + 'id' => 1, + 'sku' => 'SMBLK', + 'values' => [ + 'Size' => 'Small', + 'Colour' => 'Black', + ], + ], + ]; + + $result = MapVariantsToProductOptions::map($optionValues, $variants); + + expect($result)->toHaveCount(4); +})->group('momo');