diff options
29 files changed, 2923 insertions, 404 deletions
diff --git a/public/images/estimasi.svg b/public/images/estimasi.svg new file mode 100644 index 00000000..b4e1eb02 --- /dev/null +++ b/public/images/estimasi.svg @@ -0,0 +1,3 @@ +<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M0.75 7.41667V15.4167C0.75 20.4447 0.75 22.9593 2.31267 24.5207C3.87533 26.082 6.38867 26.0833 11.4167 26.0833H13.4167M0.75 7.41667L2.57667 4.50333C3.72467 2.66867 4.29933 1.75133 5.20333 1.25C6.10733 0.75 7.19 0.75 9.354 0.75H17.554C19.766 0.75 20.8727 0.75 21.7887 1.26867C22.7073 1.78867 23.2753 2.73667 24.414 4.634L26.0833 7.41667M0.75 7.41667H26.0833M26.0833 13.4167V7.41667M13.4167 7.41667V0.75M10.75 11.4167H16.0833M18.75 20.0833V18.0833C18.75 17.3761 19.031 16.6978 19.531 16.1977C20.0311 15.6976 20.7094 15.4167 21.4167 15.4167C22.1239 15.4167 22.8022 15.6976 23.3023 16.1977C23.8024 16.6978 24.0833 17.3761 24.0833 18.0833V20.0833M18.75 20.0833H24.0833M18.75 20.0833C18.2196 20.0833 17.7109 20.294 17.3358 20.6691C16.9607 21.0442 16.75 21.5529 16.75 22.0833V24.0833C16.75 24.6138 16.9607 25.1225 17.3358 25.4975C17.7109 25.8726 18.2196 26.0833 18.75 26.0833H24.0833C24.6138 26.0833 25.1225 25.8726 25.4975 25.4975C25.8726 25.1225 26.0833 24.6138 26.0833 24.0833V22.0833C26.0833 21.5529 25.8726 21.0442 25.4975 20.6691C25.1225 20.294 24.6138 20.0833 24.0833 20.0833" stroke="#E20613" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/public/images/garansi.svg b/public/images/garansi.svg new file mode 100644 index 00000000..e7ac6c59 --- /dev/null +++ b/public/images/garansi.svg @@ -0,0 +1,5 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.666 2.6665V7.99984M21.3327 2.6665V7.99984" stroke="#E20613" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M25.3333 5.33301H6.66667C5.19391 5.33301 4 6.52692 4 7.99967V26.6663C4 28.1391 5.19391 29.333 6.66667 29.333H25.3333C26.8061 29.333 28 28.1391 28 26.6663V7.99967C28 6.52692 26.8061 5.33301 25.3333 5.33301Z" stroke="#E20613" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M4 13.333H28M10.6667 18.6663H10.68M16 18.6663H16.0133M21.3333 18.6663H21.3467M10.6667 23.9997H10.68M16 23.9997H16.0133M21.3333 23.9997H21.3467" stroke="#E20613" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/public/images/keranjang.svg b/public/images/keranjang.svg new file mode 100644 index 00000000..6504e420 --- /dev/null +++ b/public/images/keranjang.svg @@ -0,0 +1,3 @@ +<svg width="20" height="16" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M16.22 7.54536C18.2696 7.54536 20 5.8375 20 3.77286C20 1.70821 18.2918 0 16.22 0C14.1554 0 12.4471 1.70821 12.4471 3.77286C12.4471 5.85214 14.1554 7.54536 16.22 7.54536ZM6.03786 12.1796H14.705C14.9943 12.1796 15.2468 11.9421 15.2468 11.6225C15.2468 11.3036 14.9943 11.0657 14.705 11.0657H6.16429C5.74071 11.0657 5.48071 10.7686 5.41429 10.3157L5.3025 9.53571H14.7196C15.4921 9.53571 15.9896 9.20893 16.2718 8.57036C15.6332 8.57036 15.0314 8.44429 14.4746 8.19893C14.3632 8.3475 14.2221 8.41429 14.0139 8.41429L5.13179 8.42179L4.50071 4.11464H11.4518C11.415 3.77286 11.4371 3.32714 11.4889 2.99321H4.33714L4.20357 2.04964C4.12179 1.47821 3.92143 1.18821 3.16393 1.18821H0.549643C0.2525 1.18821 0 1.44821 0 1.74536C0 2.04964 0.2525 2.30964 0.549643 2.30964H3.05964L4.24821 10.4643C4.40393 11.5189 4.96107 12.1796 6.03786 12.1796ZM16.2271 6.22357C15.9675 6.22357 15.7371 6.045 15.7371 5.77071V4.22571H14.3186C14.1986 4.22515 14.0838 4.17726 13.999 4.09245C13.9142 4.00765 13.8663 3.89279 13.8657 3.77286C13.8657 3.52786 14.0661 3.31964 14.3186 3.31964H15.7371V1.7825C15.7371 1.50036 15.9675 1.32179 16.2275 1.32179C16.4871 1.32179 16.7096 1.50036 16.7096 1.7825V3.31964H18.1286C18.3811 3.31964 18.5893 3.5275 18.5893 3.77286C18.5893 4.01821 18.3811 4.22571 18.1286 4.22571H16.71V5.77071C16.71 6.04536 16.4868 6.22357 16.2271 6.22357ZM6.625 15.7893C6.78342 15.7907 6.94054 15.7605 7.08716 15.7005C7.23379 15.6405 7.367 15.5519 7.47901 15.4398C7.59102 15.3278 7.67959 15.1945 7.73954 15.0479C7.7995 14.9013 7.82964 14.7441 7.82821 14.5857C7.82883 14.4275 7.79811 14.2708 7.73784 14.1245C7.67756 13.9782 7.58892 13.8454 7.47702 13.7335C7.36513 13.6217 7.2322 13.5332 7.0859 13.473C6.9396 13.4128 6.78284 13.3821 6.62464 13.3829C6.46628 13.3815 6.30924 13.4117 6.16267 13.4717C6.01611 13.5317 5.88296 13.6203 5.771 13.7323C5.65903 13.8443 5.57048 13.9775 5.51053 14.1241C5.45057 14.2707 5.4204 14.4277 5.42179 14.5861C5.41992 14.7446 5.44976 14.9019 5.50957 15.0487C5.56938 15.1955 5.65793 15.3289 5.77004 15.441C5.88214 15.5531 6.01552 15.6417 6.16235 15.7015C6.30917 15.7613 6.46647 15.7912 6.625 15.7893ZM13.5764 15.7893C13.7348 15.7906 13.8919 15.7604 14.0385 15.7004C14.1851 15.6404 14.3182 15.5517 14.4302 15.4397C14.5422 15.3277 14.6307 15.1944 14.6906 15.0478C14.7506 14.9012 14.7807 14.7441 14.7793 14.5857C14.7799 14.4276 14.7492 14.2709 14.689 14.1247C14.6287 13.9785 14.5402 13.8456 14.4283 13.7338C14.3165 13.622 14.1837 13.5334 14.0375 13.4732C13.8913 13.4129 13.7346 13.3822 13.5764 13.3829C12.9004 13.3829 12.3586 13.9175 12.3586 14.5861C12.3586 15.2618 12.9004 15.7893 13.5764 15.7893Z" fill="#E20613"/> +</svg> diff --git a/public/images/logo-bandingkan.svg b/public/images/logo-bandingkan.svg new file mode 100644 index 00000000..4e441182 --- /dev/null +++ b/public/images/logo-bandingkan.svg @@ -0,0 +1,3 @@ +<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5 1.42857H1.42857C0.642857 1.42857 0 2.07143 0 2.85714V12.8571C0 13.6429 0.642857 14.2857 1.42857 14.2857H5V15.7143H6.42857V0H5V1.42857ZM5 12.1429H1.42857L5 7.85714V12.1429ZM11.4286 1.42857H7.85714V2.85714H11.4286V12.1429L7.85714 7.85714V14.2857H11.4286C12.2143 14.2857 12.8571 13.6429 12.8571 12.8571V2.85714C12.8571 2.07143 12.2143 1.42857 11.4286 1.42857Z" fill="#E21F27"/> +</svg> diff --git a/public/images/no-image-compare.svg b/public/images/no-image-compare.svg new file mode 100644 index 00000000..07b0b781 --- /dev/null +++ b/public/images/no-image-compare.svg @@ -0,0 +1,9 @@ +<svg width="154" height="154" viewBox="0 0 154 154" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect opacity="0.12" width="154" height="154" fill="url(#pattern0_7532_10753)"/> +<defs> +<pattern id="pattern0_7532_10753" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_7532_10753" transform="scale(0.005)"/> +</pattern> +<image id="image0_7532_10753" width="200" height="200" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAMCElEQVR4Aeydach1VRXHbwMW2UDRgNAgvFC9WWjzQJI0D5Q0kpaVfQiCJCLIhg+ZRFpERNO3MiujiTAsaKQBKc1AsjLDBkywMgsrTMXM/os8et7nucMZ9t5nr7V/L2u9+9xzzt57rd+6f5597z333Duu+AcBCGwkgEA2ouEABFYrBMKzAAJbCCCQLXA4BAEEwnMAAlsIZBTIllk5BAEnBBCIk0IR5jIEEMgy3JnVCQEE4qRQhLkMAQSyDHdmdULAp0CcwCVM/wQQiP8akkFGAggkI1yG9k8AgfivIRlkJIBAMsJlaP8EEMieGvIQAn0CCKRPg20I7CGAQPYA4SEE+gQQSJ8G2xDYQwCB7AHCQwj0CSCQPo2824zukAACcVg0Qi5HAIGUY81MDgkgEIdFI+RyBBBIOdbM5JAAAnFYtP0hsycXAQSSiyzjhiCAQEKUkSRyEUAgucgybggCCCREGUkiFwEEkotslHEbzwOBNP4EIP3tBBDIdj4cbZwAAmn8CUD62wkgkO18ONo4AQTS+BNgyfQ9zB1FIAcF+3Ez/Gj1xSCwj0AUgZylzC6a4d9QXwwC+whEEci+xNgBgRQEEEgKiowRlgACCVvaphNLljwCSYaSgSISQCARq0pOyQggkGQoGSgiAQQSsarklIwAAkmGkoEiEtgvkIhZkhMEJhJAIBPB0a0NAgikjTqT5UQCCGQiOLq1QQCBtFFnspxIoKhAJsZINwgsRgCBLIaeiT0QQCAeqkSMixFAIIuhZ2IPBBCIhyoR42IEoghkMYBMHJsAAoldX7KbSQCBzARI99gEEEjs+pLdTAIIZCZAuscmUEIgc2/qNuSGcIfPLNNh6r9hnlWq/dycTpC9WQmBzL2p25Abwj1yJvj7qf+Qeeacw83pBNmblRCINyY1xvsiBfW6Bvwk5Xi8/FHyO8kXNwSyeAkGBXC6zrK/xNH9M8rzXPkl8r/LPy8/Vr6YIZDF0DPxDgL31PET5D+Sf0t+QF7cEEhx5L0J2RxK4Nk68WK5LTXVlDMEUo41M80jcA91/6r8lfJihkCKoWaiBATshfvZGueJ8iKGQIpgZpKEBOwzq3M03l3l2Q2BZEfMBBkI2Av2UzKMu29IBLIPSYwdDWTxFuVoSy41+QyB5GPLyHkJHKHhnybPaggkK14Gz0zg6ZnHXyGQ3IQZPyeBudfg7YwNgexExAkVE3hA7tgQSG7CacY/TsPYFcc1eM4YvqI8x1j2t3oRyJhyLHfutZr6mgb8RuU4xu4w5uQp55YQyCcU2Dsy+5Uaf479U51zx3iG5sCcESghELuE+UxxyelXafw59i91zhmfjf1xzYE5I1BCIM6QEC4EbieAQG5nwdbiBOoLAIHUVxMiqogAAqmoGIRSHwEEUl9NiKgiAgikomIQSn0EEEh9NSGiHAQmjolAJoKjWxsEEEgbdU6R5d00iN1dxL6o9G5tv1X+ArndnkdNTEMgMeuaMiu7YvajGvBqud2f6kNqT5N/UP51ue23G9o9RNvhDIGEK2nShJ6n0S6Vv0m+6Qbhd9Exuy2qnfcqbYcyBBKqnEmTsXvknqcR7yMfYrYE+5xOfIM8jA0RSJhkSWQwgYfqTLsv7pSbItjV209W/xCGQEKUMXkSH9aI9hdBzWgzUZlIsn9XY3RkEzogkAnQgnd5hPKz1x5qJtsx6vkMuXtDIO5LmDyBlyQaMdU4icKZNgwCmcYtcq/HJkrOfrou0VDLDbOwQJZLnJk3ErDPPTYeHHEg1Tgjpkx/KgJJz9T7iP9JlMBNicZZdBgEsij+Kie/IlFUqcZJFM60YRDING5jenl7u/P8McltOdd+Om3LYR+HEEjeOt1fw18gt0sx1Lgwu3nbDTMjvUX97YNGNb4trkCWr4uJ43sK4wlyu5jv02o3Xc+kQ9XY3xTJR+Rz7LPqfLncvUURiH1yO+fGb6lv6taJo39z5dfq2fJTuX0Qp6ZqO13R/Uo+xewmfnYp/JS+1fWJIpC5N6dLeVO3deLoCm/iuEgPTCxqqrXrFJl91+MPasfYX3Ty8+V2m1Q1/i2KQGqpxDZxdDHaNU623LJlV81LLnsXyn4s067o7WLf1v5AB205+Uu1YQyBpCvlEHH0Z7MX7rUvuf6qgO23ye2HauzFu92iVbtus+u1ZV+aeqFaO+ePakMZAplQzjVdxoqjG6Jbcr2m21Fp+33F9XL5veVHyo+WH5Db121NHCYSe+dKu2IZAplfz6ni6Ga2JdfZelD7kkshrm7Wf7b0ukTt7+WpPnXXUHUaAplXl7ni6M/uYcnVj7eJbQQyvcwpxdFF4WXJ1cUbvkUg00qcQxxdJN2S61PaYdtqsKUIIJDx5HOKox/NyXpQ+7tcCjG2IZDx9f2CuvQ/IdfDbHaURrYPFlO+y2XfGT9B44b4SqzyyGoIZDxe+wsyvtf0HrbMsne5PqkhbFvNJDNhnKie9kGeXUj4XW1/Uf5AObaBAALZAKbC3a9XTLbkOqh2jHXCsGurzlHHh8s7e4U2LpO/TX6YHNtDAIHsAVL5Q1ty/UwxniTfZSYMu9NhJ4yHbehgl7u8X8d+Ln+mHOsRiCYQ+2T3vspvqN9L53ozW2bZxZmbllx9YdidDm8Vxs407S/Ld3TWl+QsuwTBLJpArLh2/dBQ/5pBcOq25LpQsdsTW83qzvrv1XK7R+4YYajLIWaXlNiy61TtbX7ZFU0gqmlTZu+m2ZLrvcrallL2RSW7bagezjJbdp2pEeySkmepbdYQiP/S25P5XUojhTA0zCFmy7Nva8+X5Q+SN2cIpLmST0r4Zeply663q21q2YVAVHFsEAF7c8C+mvwLnW2/NKXmVgvcIJDAxc2Umi3l7Jem7AtUD840RzXDIpBqSuEukJcq4l/L7WYZ9itT2oxnCCReTUtmZMuu92lCe7cr5LILgai62GwCYZddCGT2c4MBegSSL7t6Yy+yiUAWwR560m7ZZVcLu08UgbgvYbUJHFFtZCMCQyAjYHFqewQQSHs1J+MRBBDICFicGorAoGQQyCBMnNQqAQTSauXJexABBDIIEye1SgCBtFp58h5EAIEMwsRJrRKYJpBWaf0/7zereTG+2sXgjWLk3hDI+BLaD3Oeq274arWNgd0hZeX9HwLxXkHiz0oAgWTFy+DeCSAQ7xUk/qwEqhNI1mwZHAIjCSCQkcA4vS0CCKStepPtSAIIZCQwTm+LAAJpq95kO5JASwIZiYbTIbBaIRCeBRDYQiCaQG7Zkuu6Q/aDO0/SAXy1qoGB/fCRyjHYxtZ78MDdidEEckOX2MD20TrvJ/iqFgbPUS3G2PVjTp5ybjSB/GkKBPq4JXBV7sijCcR+iDI3szXjs2shAnZP4KxTRxNIiLv5Za14rMHt16+yZhRNIL8TrR/LsfgEfqsUL5BntWgCMVj245PW4rEJFKlzRIGcp+fFN+VYXAL2y75nlUgvokCM28n6L8Y7WkoEO4TAP/ToRPl/5dktqkD+LHLPlV8jx+IQuE6pHC+/XF7EogrE4NlbgE/VxqVyzD+BK5TCcfIfyotZZIEYxN/ov8fLz5Bn/9RVc2DpCdykIT8mP0Zurz3UlLPoAjGS/9Z/75QfKT9VfqH8ZjlWLwF7fXGxwjtNfkB+ivxaeXFrQSAd1Ku18QG5XZR3uNqDcts+Vm2TXmHeT1FMR8nvLn+M/D3yK+WLWUsC6UO+UQ8uk9tfk/PV4qtVDQzsokl7zVjNcrhVgUgTGAR2E0AguxlxRsMEEEjDxSf13QQQyG5GnDGeQJgeCCRMKUkkBwEEkoMqY4YhgEDClJJEchBAIDmoMmYYAggkTClbSaRsngikLG9mc0YAgTgrGOGWJYBAyvJmNmcEEIizghFuWQIIpCxvZquZwJrYEMgaKOyCQEcAgXQkaCGwhgACWQOFXRDoCCCQjgQtBNYQQCBroLALAh2BVALpxqOFQCgCCCRUOUkmNQEEkpoo44UigEBClZNkUhNAIKmJMl4oAg4EEoo3yTgjgECcFYxwyxJAIGV5M5szAgjEWcEItywBBFKWN7M5I9C2QJwVi3DLE0Ag5ZkzoyMCCMRRsQi1PAEEUp45MzoigEAcFYtQyxNAIJmYM2wMAv8DAAD//4eFONUAAAAGSURBVAMA5Y3fr/7nNR4AAAAASUVORK5CYII="/> +</defs> +</svg> diff --git a/public/images/produk_asli.svg b/public/images/produk_asli.svg new file mode 100644 index 00000000..2b4cdae5 --- /dev/null +++ b/public/images/produk_asli.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.4673 30L8.93398 25.7333L4.13398 24.6667L4.60065 19.7333L1.33398 16L4.60065 12.2667L4.13398 7.33333L8.93398 6.26667L11.4673 2L16.0007 3.93333L20.534 2L23.0673 6.26667L27.8673 7.33333L27.4007 12.2667L30.6673 16L27.4007 19.7333L27.8673 24.6667L23.0673 25.7333L20.534 30L16.0007 28.0667L11.4673 30ZM12.6007 26.6L16.0007 25.1333L19.4673 26.6L21.334 23.4L25.0007 22.5333L24.6673 18.8L27.134 16L24.6673 13.1333L25.0007 9.4L21.334 8.6L19.4007 5.4L16.0007 6.86667L12.534 5.4L10.6673 8.6L7.00065 9.4L7.33398 13.1333L4.86732 16L7.33398 18.8L7.00065 22.6L10.6673 23.4L12.6007 26.6ZM14.6007 20.7333L22.134 13.2L20.2673 11.2667L14.6007 16.9333L11.734 14.1333L9.86732 16L14.6007 20.7333Z" fill="#E20613"/> +</svg> diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx index 0dc39c1c..18f90012 100644 --- a/src-migrate/modules/product-detail/components/AddToCart.tsx +++ b/src-migrate/modules/product-detail/components/AddToCart.tsx @@ -1,6 +1,6 @@ import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; import style from '../styles/price-action.module.css'; -import { Button, color, Link, useToast } from '@chakra-ui/react'; +import { Button, ButtonProps, Link, useToast } from '@chakra-ui/react'; import product from 'next-seo/lib/jsonld/product'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -26,6 +26,9 @@ type Props = { quantity?: number; source?: 'buy' | 'add_to_cart'; products: IProductDetail; + + buttonProps?: ButtonProps; + children?: (props: { onClick: () => Promise<void>; isLoading: boolean }) => React.ReactNode; }; type Status = 'idle' | 'loading' | 'success'; @@ -35,6 +38,9 @@ const AddToCart = ({ quantity = 1, source = 'add_to_cart', products, + + buttonProps, + children, }: Props) => { let auth = getAuth(); const router = useRouter(); @@ -140,7 +146,10 @@ const AddToCart = ({ }); setStatus('idle'); setRefreshCart(true); - setAddCartAlert(true); + + if (!children) { + setAddCartAlert(true); + } gtagAddToCart(activeVariant, quantity); @@ -164,6 +173,14 @@ const AddToCart = ({ }, 3000); }, [status]); + if (children) { + return ( + <div className='w-full'> + {children({ onClick: handleButton, isLoading: status === 'loading' })} + </div> + ); + } + const btnConfig = { add_to_cart: { colorScheme: 'red', @@ -186,6 +203,8 @@ const AddToCart = ({ variant={btnConfig[source].variant} className='w-full' isDisabled={!hasPrice || status === 'loading'} + + {...buttonProps} > {btnConfig[source].text} </Button> @@ -198,6 +217,8 @@ const AddToCart = ({ variant={btnConfig[source].variant} className='w-full' isDisabled={!hasPrice || status === 'loading'} + + {...buttonProps} > {btnConfig[source].text} </Button> diff --git a/src-migrate/modules/product-detail/components/AddToQuotation.tsx b/src-migrate/modules/product-detail/components/AddToQuotation.tsx index 3e811330..e26e271f 100644 --- a/src-migrate/modules/product-detail/components/AddToQuotation.tsx +++ b/src-migrate/modules/product-detail/components/AddToQuotation.tsx @@ -1,7 +1,7 @@ import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; import style from '../styles/price-action.module.css'; import { Button, Link, useToast } from '@chakra-ui/react'; -import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; +// import { ScaleIcon } from '@heroicons/react/24/outline'; // Tidak perlu lagi import product from 'next-seo/lib/jsonld/product'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -17,12 +17,15 @@ import { createSlug } from '~/libs/slug'; import formatCurrency from '~/libs/formatCurrency'; import { useProductDetail } from '../stores/useProductDetail'; import useDevice from '@/core/hooks/useDevice'; +import DesktopView from '@/core/components/views/DesktopView'; +import MobileView from '@/core/components/views/MobileView'; type Props = { variantId: number | null; quantity?: number; source?: 'buy' | 'add_to_cart'; products: IProductDetail; + onCompare?: () => void; }; type Status = 'idle' | 'loading' | 'success'; @@ -32,6 +35,7 @@ const AddToQuotation = ({ quantity = 1, source = 'add_to_cart', products, + onCompare }: Props) => { const auth = getAuth(); const router = useRouter(); @@ -106,37 +110,60 @@ const AddToQuotation = ({ }, 3000); }, [status]); - const btnConfig = { - add_to_cart: { - colorScheme: 'red', - variant: 'outline', - text: 'Keranjang', - }, - buy: { - colorScheme: 'red', - variant: 'solid', - text: 'Beli', - }, - }; - return ( - <div className='w-full'> - <Button - onClick={handleButton} - color={'red'} - colorScheme='white' - className='w-full border-2 p-2 gap-1 hover:bg-slate-100 flex items-center' - isDisabled={!hasPrice} - > - <ImageNext - src={isDesktop ? '/images/doc_red.svg' : '/images/doc.svg'} - alt='penawaran instan' - className='' - width={25} - height={25} - /> - {isDesktop ? 'Penawaran Harga Instan' : ''} - </Button> + <div className='w-full flex flex-col gap-3'> + + {/* 3. TAMPILAN DESKTOP: GRID 2 KOLOM (Bandingkan & Penawaran) */} + <DesktopView> + <div className="grid grid-cols-2 gap-3 w-full"> + {/* Tombol Kiri: Bandingkan */} + <Button + onClick={onCompare} + variant="outline" + colorScheme="gray" + className="w-full border border-gray-300 p-2 gap-2 flex items-center justify-center text-gray-600 hover:text-red-600 hover:border-red-600 transition-all font-normal text-sm" + > + {/* UPDATE ICON DISINI */} + <ImageNext src="/images/logo-bandingkan.svg" width={15} height={15} alt="bandingkan" /> + Bandingkan + </Button> + + {/* Tombol Kanan: Penawaran (Link WA) */} + <Button + as={Link} + href={askAdminUrl} + target='_blank' + variant="outline" + colorScheme="gray" + className="w-full border border-gray-300 p-2 gap-2 flex items-center justify-center text-gray-600 hover:text-red-600 hover:border-red-600 transition-all font-normal text-sm" + _hover={{ textDecoration: 'none' }} + onClick={handleButton} + > + <ImageNext src="/images/doc_red.svg" width={20} height={20} alt="penawaran" /> + Penawaran + </Button> + </div> + </DesktopView> + + {/* TAMPILAN MOBILE */} + <MobileView> + <Button + onClick={handleButton} + color={'red'} + colorScheme='white' + className='w-full border-2 p-2 gap-1 hover:bg-slate-100 flex items-center' + isDisabled={!hasPrice} + > + <ImageNext + src='/images/doc.svg' + alt='penawaran instan' + className='' + width={25} + height={25} + /> + </Button> + </MobileView> + <BottomPopup className='!container' title='Berhasil Ditambahkan' @@ -243,4 +270,4 @@ const AddToQuotation = ({ ); }; -export default AddToQuotation; +export default AddToQuotation;
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/Information.tsx b/src-migrate/modules/product-detail/components/Information.tsx index 813b6bf5..236a03af 100644 --- a/src-migrate/modules/product-detail/components/Information.tsx +++ b/src-migrate/modules/product-detail/components/Information.tsx @@ -9,19 +9,35 @@ import style from '../styles/information.module.css'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; +import axios from 'axios'; import currencyFormat from '@/core/utils/currencyFormat'; -import { InputGroup, InputRightElement } from '@chakra-ui/react'; +import { + InputGroup, + InputRightElement, + SimpleGrid, + Flex, + Text, + Box, + Center, + Icon, +} from '@chakra-ui/react'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import Image from 'next/image'; +import ImageNext from 'next/image'; import { formatToShortText } from '~/libs/formatNumber'; import { createSlug } from '~/libs/slug'; import { IProductDetail } from '~/types/product'; import { useProductDetail } from '../stores/useProductDetail'; import useVariant from '../hook/useVariant'; +// Import View Components +import MobileView from '@/core/components/views/MobileView'; // Pastikan path import benar + +// Import Modal Compare +import ProductComparisonModal from './ProductComparisonModal'; + const Skeleton = dynamic(() => - import('@chakra-ui/react').then((mod) => mod.Skeleton) + import('@chakra-ui/react').then((mod) => mod.Skeleton), ); type Props = { @@ -37,14 +53,46 @@ const Information = ({ product }: Props) => { const inputRef = useRef<HTMLInputElement>(null); // source of truth - const variantOptions = product.variants; + // const variantOptions = product.variants; + const [variantOptions, setVariantOptions] = useState<any[]>( + product?.variants, + ); const variantId = selectedVariant?.id; const { slaVariant, isLoading } = useVariant({ variantId }); - /* ====================== - * Sync input text - * ====================== */ + const [warranties, setWarranties] = useState<Record<string, string>>({}); + const [loadingWarranty, setLoadingWarranty] = useState(false); + + // State untuk Modal Compare + const [isCompareOpen, setIsCompareOpen] = useState(false); + + useEffect(() => { + const fetchWarrantyDirectly = async () => { + if (!product?.variants || product.variants.length === 0) return; + + setLoadingWarranty(true); + try { + const skus = product.variants.map((v) => v.id).join(','); + const mainSku = product.variants[0].id; + + const res = await axios.get('/api/magento-product', { + params: { skus, main_sku: mainSku }, + }); + + if (res.data && res.data.warranties) { + setWarranties(res.data.warranties); + } + } catch (error) { + // console.error("Gagal ambil garansi:", error); + } finally { + setLoadingWarranty(false); + } + }; + + fetchWarrantyDirectly(); + }, [product]); + useEffect(() => { if (!selectedVariant) return; @@ -52,7 +100,7 @@ const Information = ({ product }: Props) => { selectedVariant.code + (selectedVariant.attributes?.[0] ? ` - ${selectedVariant.attributes[0]}` - : '') + : ''), ); }, [selectedVariant]); @@ -72,14 +120,19 @@ const Information = ({ product }: Props) => { /* ====================== * Handlers * ====================== */ - const handleOnChange = (value: string) => { + const handleOnChange = (vals: any) => { setDisableFilter(true); + let code = vals.replace(/\s-\s.*$/, '').trim(); + let variant = product?.variants.find((item) => item.code === code); - const variant = variantOptions.find((item) => String(item.id) === value); - - if (!variant) return; - - setSelectedVariant(variant); + if (variant) { + setSelectedVariant(variant); + setInputValue( + variant?.code + + (variant?.attributes[0] ? ' - ' + variant?.attributes[0] : ''), + ); + setVariantOptions(product?.variants); + } }; const handleOnKeyUp = (e: any) => { @@ -87,6 +140,14 @@ const Information = ({ product }: Props) => { setInputValue(e.target.value); }; + const rowStyle = { + backgroundColor: '#ffffff', + fontSize: '13px', + borderBottom: '1px dashed #e2e8f0', + padding: '8px 0', + marginBottom: '0px', + }; + return ( <div className={style['wrapper']}> {/* ===== Variant Selector ===== */} @@ -120,70 +181,133 @@ const Information = ({ product }: Props) => { </InputGroup> <AutoCompleteList> - {variantOptions.map((option) => ( - <AutoCompleteItem - key={option.id} - value={String(option.id)} - _selected={ - option.id === selectedVariant?.id - ? { bg: 'gray.300' } - : undefined - } - > - <div className='flex gap-x-2 w-full justify-between px-3 items-center p-2'> - <div className='text-small'> - {option.code} - {option.attributes?.[0] ? ` - ${option.attributes[0]}` : ''} - </div> - - <div - className={ - option.price?.discount_percentage - ? 'flex gap-x-4 items-center' - : '' - } - > - {option.price?.discount_percentage > 0 && ( - <> - <div className='badge-solid-red text-xs'> - {Math.floor(option.price.discount_percentage)}% - </div> - <div className='min-w-16 sm:min-w-24 text-gray_r-11 line-through text-[11px] sm:text-caption-2'> - {currencyFormat(option.price.price)} - </div> - </> - )} - <div className='min-w-20 sm:min-w-28 text-danger-500 font-semibold'> - {currencyFormat(option.price.price_discount)} + {variantOptions + .sort((a: any, b: any) => { + return a.code.localeCompare(b.code, undefined, { + numeric: true, + sensitivity: 'base', + }); + }) + .map((option, cid) => ( + <AutoCompleteItem + key={option.id} + // value={String(option.id)} + value={ + option.code + + (option.attributes?.[0] ? ` - ${option.attributes[0]}` : '') + } + _selected={ + option.id === selectedVariant?.id + ? { bg: 'gray.300' } + : undefined + } + > + <div className='flex gap-x-2 w-full justify-between px-3 items-center p-2'> + <div className='text-small'> + {option.code} + {option.attributes?.[0] + ? ` - ${option.attributes[0]}` + : ''} + </div> + <div + className={ + option?.price?.discount_percentage + ? 'flex gap-x-4 items-center justify-between' + : '' + } + > + {option?.price?.discount_percentage > 0 && ( + <> + <div className='badge-solid-red text-xs'> + {Math.floor(option.price.discount_percentage)}% + </div> + <div className='min-w-16 sm:min-w-24 text-gray_r-11 line-through text-[11px] sm:text-caption-2'> + {currencyFormat(option.price.price)} + </div> + </> + )} + <div className='min-w-20 sm:min-w-28 text-danger-500 font-semibold'> + {currencyFormat(option.price.price_discount)} + </div> </div> </div> - </div> - </AutoCompleteItem> - ))} + </AutoCompleteItem> + ))} </AutoCompleteList> </AutoComplete> + + {/* === TOMBOL BANDINGKAN PRODUK (HANYA MOBILE) === */} + <MobileView> + <div + className='w-full flex items-center justify-between py-3 px-1 mt-3 bg-white border-t border-b border-black-100 cursor-pointer hover:bg-gray-50 transition-colors group' + onClick={() => setIsCompareOpen(true)} + > + <div className='flex items-center gap-3'> + <div className='bg-red-50 p-2 rounded-full group-hover:bg-red-100 transition-colors'> + <ImageNext + src='/images/logo-bandingkan.svg' + width={15} + height={15} + alt='bandingkan' + /> + </div> + <div className='flex flex-col'> + <span className='text-sm font-bold text-gray-800'> + Bandingkan Produk + </span> + <span className='text-xs text-gray-500'> + Coba bandingkan dengan produk lainnya + </span> + </div> + </div> + <div className='flex items-center gap-2'> + <span className='bg-red-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full'> + Baru + </span> + <Icon + as={ChevronDownIcon} + className='w-4 h-4 text-gray-400 transform -rotate-90' + /> + </div> + </div> + </MobileView> + + {/* Render Modal (Logic open/close ada di dalam component) */} + {isCompareOpen && ( + <ProductComparisonModal + isOpen={isCompareOpen} + onClose={() => setIsCompareOpen(false)} + mainProduct={product} + selectedVariant={selectedVariant} + /> + )} </div> - {/* ===== Info Rows ===== */} - <div className={style['row']}> - <div className={style['label']}>Item Code</div> + {/* ITEM CODE */} + <div className={style['row']} style={rowStyle}> + <div className={style['label']} style={{ color: '#6b7280' }}> + Item Code + </div> <div className={style['value']}>{selectedVariant?.code}</div> </div> - <div className={style['row']}> - <div className={style['label']}>Manufacture</div> + {/* MANUFACTURE */} + <div className={style['row']} style={rowStyle}> + <div className={style['label']} style={{ color: '#6b7280' }}> + Manufacture + </div> <div className={style['value']}> {!!product.manufacture.name ? ( <Link href={createSlug( '/shop/brands/', product.manufacture.name, - product.manufacture.id.toString() + product.manufacture.id.toString(), )} > - {product.manufacture.logo ? ( - <Image - height={50} + {product?.manufacture.logo ? ( + <ImageNext + height={100} width={100} src={product.manufacture.logo} alt={product.manufacture.name} @@ -201,29 +325,143 @@ const Information = ({ product }: Props) => { </div> </div> - <div className={style['row']}> - <div className={style['label']}>Berat Barang</div> + {/* BERAT BARANG */} + <div className={style['row']} style={rowStyle}> + <div className={style['label']} style={{ color: '#6b7280' }}> + Berat Barang + </div> <div className={style['value']}> {selectedVariant?.weight > 0 ? `${selectedVariant.weight} Kg` : '-'} </div> </div> - <div className={style['row']}> - <div className={style['label']}>Terjual</div> + {/* TERJUAL */} + <div + className={style['row']} + style={{ ...rowStyle, borderBottom: 'none' }} + > + <div className={style['label']} style={{ color: '#6b7280' }}> + Terjual + </div> <div className={style['value']}> {product.qty_sold > 0 ? formatToShortText(product.qty_sold) : '-'} </div> </div> - <div className={style['row']}> - <div className={style['label']}>Persiapan Barang</div> - {isLoading ? ( - <div className={style['value']}> - <Skeleton height={5} width={100} /> - </div> - ) : ( - <div className={style['value']}>{sla?.sla_date}</div> - )} + {/* === DETAIL INFORMASI PRODUK === */} + <div className='mt-6 border-t pt-4'> + <h2 className='hidden md:block font-bold text-gray-800 text-sm mb-4'> + Detail Informasi Produk + </h2> + + <SimpleGrid columns={{ base: 3, md: 3 }} spacing={{ base: 2, md: 10 }}> + <Flex + direction={{ base: 'column', md: 'row' }} + align='center' + textAlign={{ base: 'center', md: 'left' }} + gap={{ base: 2, md: 3 }} + > + <img + src='/images/produk_asli.svg' + alt='Distributor Resmi' + className='w-8 h-8 md:w-10 md:h-10 shrink-0' + /> + <Box> + <Text + fontSize={{ base: '10px', md: '11px' }} + color='gray.500' + lineHeight='short' + mb='1px' + > + Distributor Resmi + </Text> + <Text + fontSize={{ base: '10px', md: '12px' }} + fontWeight='bold' + color='gray.800' + lineHeight='1.2' + > + Jaminan Produk Asli + </Text> + </Box> + </Flex> + + <Flex + direction={{ base: 'column', md: 'row' }} + align='center' + textAlign={{ base: 'center', md: 'left' }} + gap={{ base: 2, md: 3 }} + > + <img + src='/images/estimasi.svg' + alt='Estimasi Penyiapan' + className='w-8 h-8 md:w-9 md:h-9 shrink-0' + /> + <Box> + <Text + fontSize={{ base: '10px', md: '11px' }} + color='gray.500' + lineHeight='short' + mb='1px' + > + Estimasi Penyiapan + </Text> + {isLoading ? ( + <Center> + <Skeleton height='10px' width='50px' mt='2px' /> + </Center> + ) : ( + <Text + fontSize={{ base: '10px', md: '12px' }} + fontWeight='bold' + color='gray.800' + lineHeight='1.2' + > + {sla?.sla_date || '-'} + </Text> + )} + </Box> + </Flex> + + <Flex + direction={{ base: 'column', md: 'row' }} + align='center' + textAlign={{ base: 'center', md: 'left' }} + gap={{ base: 2, md: 3 }} + > + <img + src='/images/garansi.svg' + alt='Garansi Produk' + className='w-8 h-8 md:w-10 md:h-10 shrink-0' + /> + <Box> + <Text + fontSize={{ base: '10px', md: '11px' }} + color='gray.500' + lineHeight='short' + mb='1px' + > + Garansi Produk + </Text> + {loadingWarranty ? ( + <Center> + <Skeleton height='10px' width='50px' mt='2px' /> + </Center> + ) : ( + <Text + fontSize={{ base: '10px', md: '12px' }} + fontWeight='bold' + color='gray.800' + lineHeight='1.2' + > + {selectedVariant && warranties[selectedVariant.id] + ? warranties[selectedVariant.id] + : '-'} + </Text> + )} + </Box> + </Flex> + </SimpleGrid> </div> </div> ); diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx index d73ab5f6..ea65b3d1 100644 --- a/src-migrate/modules/product-detail/components/PriceAction.tsx +++ b/src-migrate/modules/product-detail/components/PriceAction.tsx @@ -1,5 +1,4 @@ import style from '../styles/price-action.module.css'; - import Image from 'next/image'; import Link from 'next/link'; import { useEffect, useState } from 'react'; @@ -15,14 +14,17 @@ import { Button, Skeleton } from '@chakra-ui/react'; import DesktopView from '@/core/components/views/DesktopView'; import MobileView from '@/core/components/views/MobileView'; +// 1. Tambahkan onCompare (Optional) di sini type Props = { product: IProductDetail; + onCompare?: () => void; }; const PPN: number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; -const PriceAction = ({ product }: Props) => { + +const PriceAction = ({ product, onCompare }: Props) => { const { activePrice, setActive, @@ -146,19 +148,6 @@ const PriceAction = ({ product }: Props) => { </> )} - {/* {!!activePrice && activePrice.price === 0 && ( - <span> - Hubungi kami untuk dapatkan harga terbaik,{' '} - <Link - href={askAdminUrl} - target='_blank' - className={style['contact-us']} - > - klik disini - </Link> - </span> - )} */} - <DesktopView> <div className='h-4' /> <div className='flex gap-x-5 items-center'> @@ -227,9 +216,6 @@ const PriceAction = ({ product }: Props) => { )} </div> </div> - {/* <span className='text-[12px] text-red-500 italic'> - * {qtyPickUp} barang bisa di pickup - </span> */} </DesktopView> {/* ===== MOBILE: grid kiri-kanan, kanan hanya qty ===== */} @@ -263,12 +249,6 @@ const PriceAction = ({ product }: Props) => { </Link> )} </div> - - {/* {qtyPickUp > 0 && ( - <div className='text-[12px] mt-1 text-red-500 italic'> - * {qtyPickUp} barang bisa di pickup - </div> - )} */} </div> {/* Kanan: hanya qty, rata kanan */} @@ -295,9 +275,9 @@ const PriceAction = ({ product }: Props) => { value={quantityInput} onChange={(e) => setQuantityInput(e.target.value)} className='h-11 md:h-12 w-16 md:w-20 text-center text-lg md:text-xl outline-none border-x - [appearance:textfield] - [&::-webkit-outer-spin-button]:appearance-none - [&::-webkit-inner-spin-button]:appearance-none' + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none' disabled={!hasPrice} /> @@ -322,24 +302,26 @@ const PriceAction = ({ product }: Props) => { <div className={`${style['action-wrapper']}`}> <AddToCart products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> {!isApproval && ( <AddToCart source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> )} </div> <div className='mt-4'> + {/* 2. TERUSKAN onCompare KE SINI */} <AddToQuotation source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} + onCompare={onCompare} /> </div> </DesktopView> @@ -349,14 +331,14 @@ const PriceAction = ({ product }: Props) => { <AddToQuotation source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> </div> <div className='col-span-5'> <AddToCart products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> </div> @@ -365,7 +347,7 @@ const PriceAction = ({ product }: Props) => { <AddToCart source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> )} @@ -376,4 +358,4 @@ const PriceAction = ({ product }: Props) => { ); }; -export default PriceAction; +export default PriceAction;
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx new file mode 100644 index 00000000..5dd3f175 --- /dev/null +++ b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx @@ -0,0 +1,1084 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + Drawer, + DrawerOverlay, + DrawerContent, + DrawerHeader, + DrawerBody, + DrawerCloseButton, + Button, + Text, + Box, + Badge, + Grid, + GridItem, + Image, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + VStack, + HStack, + IconButton, + Flex, + Icon, + Spinner, + List, + ListItem, + useToast, + useOutsideClick, + useBreakpointValue, + Divider, + ScaleFade +} from '@chakra-ui/react'; + +import { + AutoComplete, + AutoCompleteInput, + AutoCompleteItem, + AutoCompleteList, +} from '@choc-ui/chakra-autocomplete'; + +import { Search, Trash2, ChevronDown, X, Plus } from 'lucide-react'; + +import AddToCart from './AddToCart'; + +// --- HELPER FORMATTING --- +const formatPrice = (price: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(price); +}; + +const renderSpecValue = (val: any) => { + if (!val || val === '-') return '-'; + // return String(val).replace(/<[^>]*>?/gm, ''); + return String(val); +}; + +const extractAttribute = (item: any) => { + if (item.attributes && item.attributes.length > 0) { + return item.attributes[0]; + } + + const textToParse = item.displayName || item.name || ''; + const match = textToParse.match(/\(([^)]+)\)$/); + + if (match) { + return match[1]; + } + + const code = item.code || item.defaultCode || ''; + return textToParse.replace(`[${code}]`, '').replace(code, '').trim(); +}; + +const getVariantLabel = (v: any) => { + const attr = extractAttribute(v); + return `${v.code} - ${attr}`; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; + mainProduct: any; + selectedVariant: any; +}; + +const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant }: Props) => { + const toast = useToast(); + const isMobile = useBreakpointValue({ base: true, md: false }); + + const [products, setProducts] = useState<(any | null)[]>([null, null]); + const [specsMatrix, setSpecsMatrix] = useState<any[]>([]); + const [isLoadingMatrix, setIsLoadingMatrix] = useState(false); + + // Search State + const [activeSearchSlot, setActiveSearchSlot] = useState<number | null>(null); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState<any[]>([]); + const [isSearching, setIsSearching] = useState(false); + + const [disableVariantFilter, setDisableVariantFilter] = useState(false); + + const searchWrapperRef = useRef<HTMLDivElement>(null); + + useOutsideClick({ + ref: searchWrapperRef, + handler: () => { + if (activeSearchSlot !== null) { + setActiveSearchSlot(null); + setSearchResults([]); + } + }, + }); + + // =========================================================================== + // 1. LOGIC UTAMA: ISI SLOT 1 + // =========================================================================== + useEffect(() => { + if (isOpen && mainProduct) { + let activeItem = selectedVariant; + + if (!activeItem && mainProduct.variants && mainProduct.variants.length > 0) { + activeItem = mainProduct.variants[0]; + } + if (!activeItem) { + activeItem = mainProduct; + } + + const targetId = activeItem.id; + const displayCode = activeItem.default_code || activeItem.code || activeItem.sku || mainProduct.default_code || mainProduct.code; + + const variantOptions = mainProduct.variants?.map((v: any) => ({ + id: v.id, + code: v.default_code || v.code || v.sku, + name: v.name || v.displayName || v.display_name, + displayName: v.displayName || v.name, + price: v.price?.price || v.price || 0, + image: v.image, + attributes: v.attributes || [] + })) || []; + + if (variantOptions.length === 0) { + variantOptions.push({ + id: targetId, + code: displayCode, + name: mainProduct.name, + displayName: mainProduct.displayName || mainProduct.name, + price: activeItem.price?.price || activeItem.price || 0, + image: activeItem.image || mainProduct.image, + attributes: [] + }); + } + + const displayName = activeItem.name || activeItem.displayName || mainProduct.name; + + const tempActiveVar = { + code: displayCode, + name: displayName, + displayName: displayName, + attributes: activeItem.attributes || [] + }; + + const productSlot1 = { + id: targetId, + sku: targetId, + realCode: displayCode, + name: displayName, + price: activeItem.price?.price || activeItem.price || mainProduct.lowest_price?.price || 0, + image: activeItem.image || mainProduct.image, + variants: variantOptions, + inputValue: getVariantLabel(tempActiveVar) + }; + + setProducts((prev) => { + const newSlots = [...prev]; + if (!newSlots[0] || String(newSlots[0].id) !== String(targetId)) { + newSlots[0] = productSlot1; + } + return newSlots; + }); + } + }, [isOpen, mainProduct, selectedVariant]); + + // =========================================================================== + // 2. FETCH SPECS + // =========================================================================== + useEffect(() => { + const validProducts = products.filter(p => p !== null); + if (!isOpen || validProducts.length === 0) return; + + const fetchSpecs = async () => { + setIsLoadingMatrix(true); + try { + const allSkus = validProducts.map(p => p.sku).join(','); + const mainSku = validProducts[0]?.sku; + + const res = await fetch(`/api/magento-product?skus=${allSkus}&main_sku=${mainSku}`); + if (!res.ok) return; + + const data = await res.json(); + if (data.specsMatrix) { + setSpecsMatrix(data.specsMatrix); + } + } catch (err) { + console.error(err); + } finally { + setIsLoadingMatrix(false); + } + }; + + fetchSpecs(); + }, [products, isOpen]); + + // =========================================================================== + // 3. SEARCH LOGIC + // =========================================================================== + useEffect(() => { + const delayDebounceFn = setTimeout(async () => { + if (searchQuery.length > 0 && searchQuery.length < 3) { + setSearchResults([]); + return; + } + + if (activeSearchSlot === null) return; + + const attrSetId = selectedVariant?.attribute_set_id || mainProduct?.attribute_set_id; + + if (!attrSetId) { + setSearchResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + try { + let queryParam = '*'; + + if (searchQuery !== '') { + const words = searchQuery.trim().split(/\s+/); + queryParam = words.map(w => `*${w}*`).join(' '); + } + + const params = new URLSearchParams({ + source: 'compare', + q: queryParam, + limit: '20', + fq: `attribute_set_id_i:${attrSetId}`, + group: 'false' + }); + + const res = await fetch(`/api/shop/search?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setSearchResults(data.response?.products || []); + } else { + setSearchResults([]); + } + } catch (e) { + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, 500); + + return () => clearTimeout(delayDebounceFn); + }, [searchQuery, mainProduct, selectedVariant, activeSearchSlot]); + + // =========================================================================== + // 4. HANDLERS + // =========================================================================== + + const handleInputChange = (slotIndex: number, newValue: string) => { + setDisableVariantFilter(false); + const newProducts = [...products]; + if (newProducts[slotIndex]) { + newProducts[slotIndex] = { + ...newProducts[slotIndex], + inputValue: newValue + }; + setProducts(newProducts); + } + }; + + const handleVariantChange = (slotIndex: number, selectedValueString: string) => { + const currentProduct = products[slotIndex]; + if (!currentProduct || !currentProduct.variants) return; + + const selectedVar = currentProduct.variants.find((v: any) => { + return getVariantLabel(v) === selectedValueString; + }); + + if (selectedVar) { + const isDuplicate = products.some((p, idx) => + idx !== slotIndex && + p !== null && + String(p.id) === String(selectedVar.id) + ); + + if (isDuplicate) { + toast({ + title: "Varian sudah ada", + description: "Varian produk ini sudah ada di slot perbandingan lain.", + status: "warning", + position: "top", + duration: 3000 + }); + return; + } + + setDisableVariantFilter(true); + + const newProducts = [...products]; + newProducts[slotIndex] = { + ...currentProduct, + id: selectedVar.id, + sku: selectedVar.id, + name: selectedVar.name, + realCode: selectedVar.code, + price: selectedVar.price, + image: selectedVar.image, + inputValue: getVariantLabel(selectedVar) + }; + setProducts(newProducts); + } + }; + + const handleAddProduct = async (searchItem: any, slotIndex: number) => { + if (products.some(p => p !== null && String(p.id) === String(searchItem.id))) { + toast({ title: "Produk sudah ada", status: "warning", position: "top" }); + return; + } + setDisableVariantFilter(true); + + const idToAdd = searchItem.id; + const codeToAdd = searchItem.defaultCode || searchItem.default_code || searchItem.code; + const nameToAdd = searchItem.displayName || searchItem.name; + const imageToAdd = searchItem.image || searchItem.imageS || searchItem.image_s; + const priceToAdd = searchItem.lowestPrice?.price || searchItem.priceTier1V2F || searchItem.price || 0; + + let parentId = searchItem.templateId || + searchItem.templateIdI || + searchItem.template_id_i || + searchItem.template_id; + + if (!parentId) { + try { + const checkParams = new URLSearchParams({ source: 'upsell', q: '*:*', fq: `id:${idToAdd}` }); + const checkRes = await fetch(`/api/shop/search?${checkParams.toString()}`); + if (checkRes.ok) { + const checkData = await checkRes.json(); + const freshItem = checkData.response?.products?.[0]; + if (freshItem) { + const serverReturnedId = freshItem.id; + if (String(serverReturnedId) !== String(idToAdd)) { + parentId = serverReturnedId; + } else { + parentId = freshItem.templateId || freshItem.templateIdI || idToAdd; + } + } + } + } catch (e) { + console.error("Gagal validasi parent:", e); + parentId = idToAdd; + } + } + + const tempVar = { + code: codeToAdd, + name: nameToAdd, + displayName: searchItem.displayName || nameToAdd, + attributes: searchItem.attributes || [] + }; + const initialLabel = getVariantLabel(tempVar); + + const newProductEntry = { + id: idToAdd, + sku: idToAdd, + realCode: codeToAdd, + name: nameToAdd, + price: priceToAdd, + image: imageToAdd, + variants: [{ + id: idToAdd, + code: codeToAdd, + name: nameToAdd, + displayName: searchItem.displayName || nameToAdd, + price: priceToAdd, + image: imageToAdd, + attributes: searchItem.attributes || [] + }], + inputValue: initialLabel + }; + + setProducts((prev) => { + const newSlots = [...prev]; + newSlots[slotIndex] = newProductEntry; + return newSlots; + }); + + setActiveSearchSlot(null); + setSearchQuery(''); + setSearchResults([]); + + if (parentId) { + try { + const params = new URLSearchParams({ + source: 'upsell', + limit: '100', + fq: `template_id_i:${parentId}` + }); + + const res = await fetch(`/api/shop/search?${params.toString()}`); + + if (res.ok) { + const data = await res.json(); + const siblings = data.response?.products || []; + + if (siblings.length > 0) { + const allVariants = siblings.map((s: any) => ({ + id: s.variantId || s.productIdI || s.id, + code: s.defaultCode || s.default_code || s.code, + name: s.displayName || s.name || s.nameS, + displayName: s.displayName, + price: s.lowestPrice?.price || s.priceTier1V2F || 0, + image: s.image || s.imageS, + attributes: [] + })); + + allVariants.sort((a: any, b: any) => + String(a.code).localeCompare(String(b.code)) + ); + + setProducts((prev) => { + const updated = [...prev]; + if (updated[slotIndex] && String(updated[slotIndex].id) === String(idToAdd)) { + updated[slotIndex] = { + ...updated[slotIndex], + variants: allVariants + }; + } + return updated; + }); + } + } + } catch (error) { + console.error("Gagal fetch variant lain:", error); + } + } + }; + + const handleRemoveProduct = (index: number) => { + const newProducts = [...products]; + + if (newProducts.length > 2) { + newProducts.splice(index, 1); + } else { + newProducts[index] = null; + } + + setProducts(newProducts); + if (newProducts.every(p => p === null)) setSpecsMatrix([]); + }; + + const handleAddSlot = () => { + if (products.length < 4) { + setProducts([...products, null]); + } + }; + + // --- RENDER SLOT ITEM (REUSABLE) --- + const renderProductSlot = (product: any, index: number) => { + let content; + if (product) { + + const productPayload = { + ...mainProduct, + id: product.id, + name: product.name, + price: product.price, + image: product.image + }; + + content = ( + <VStack align="stretch" spacing={3} h="100%"> + {index !== 0 && ( + <IconButton + aria-label="Hapus" icon={<Trash2 size={16} />} + size="xs" position="absolute" top={-2} right={-2} + colorScheme="red" onClick={() => handleRemoveProduct(index)} zIndex={2} + /> + )} + <Box h="160px" display="flex" alignItems="center" justifyContent="center" bg="white" borderRadius="md" p={2}> + <Image + src={product.image || '/images/no-image-compare.svg'} + alt={product.name} maxH="100%" objectFit="contain" + onError={(e) => { (e.target as HTMLImageElement).src = '/images/no-image-compare.svg'; }} + /> + </Box> + <Box> + <Text color="red.600" fontWeight="bold" fontSize="md"> + {product.price > 0 ? formatPrice(product.price) : 'Hubungi Admin'} + </Text> + <Text fontSize="xs" fontWeight="bold" noOfLines={3} h="45px" title={product.name} mb={2}> + {product.name} + </Text> + </Box> + + <Box w="100%"> + <AutoComplete + openOnFocus + disableFilter={disableVariantFilter} + onChange={(val) => handleVariantChange(index, val)} + value={product.inputValue} + > + <InputGroup size="sm"> + <AutoCompleteInput + variant="outline" + fontSize="sm" + _focus={{ + fontSize: {base: '16px', md: 'sm'}, + borderColor: 'red.500', + }}borderRadius="md" + placeholder="Cari Varian..." + value={product.inputValue} + onChange={(e) => handleInputChange(index, e.target.value)} + onFocus={() => setDisableVariantFilter(true)} + /> + <InputRightElement h="100%" pointerEvents="none"> + <Icon as={ChevronDown} color="gray.400" size={14} /> + </InputRightElement> + </InputGroup> + + <AutoCompleteList fontSize="xs" maxH="250px" overflowY="auto" p={0}> + {product.variants && product.variants.map((v: any, vIdx: number) => { + const attributeText = extractAttribute(v); + const label = `${v.code} - ${attributeText}`; + return ( + <AutoCompleteItem + key={`option-${vIdx}`} + value={label} + textTransform="capitalize" + _selected={{ bg: 'red.50', borderLeft: '3px solid red' }} + _focus={{ bg: 'gray.50' }} + p={2} + borderBottom="1px dashed" + borderColor="gray.200" + > + <Flex justify="space-between" align="start" w="100%"> + <Box flex={1} mr={2} overflow="hidden"> + <Text fontWeight="bold" fontSize="xs" color="gray.700">{v.code}</Text> + <Text fontSize="xs" color="gray.500" noOfLines={1} title={attributeText} textTransform="capitalize"> + {attributeText} + </Text> + </Box> + <Text color="red.600" fontWeight="bold" fontSize="xs" whiteSpace="nowrap" bg="red.50" px={2} py={0.5} borderRadius="md" h="fit-content"> + {formatPrice(v.price)} + </Text> + </Flex> + </AutoCompleteItem> + ); + })} + </AutoCompleteList> + </AutoComplete> + </Box> + +{/* [UBAH BAGIAN TOMBOL ACTION INI] */} + <HStack spacing={2} w="100%" pt={2}> + + {/* 1. TOMBOL KERANJANG */} + {/* Bungkus dengan Box w="auto" agar ukurannya pas mengikuti icon */} + <Box w="auto"> + <AddToCart + products={productPayload} + variantId={product.id} + quantity={1} + > + {({ onClick, isLoading }) => ( + <IconButton + aria-label="Cart" + icon={<Image src="/images/keranjang.svg" w="15px" h="15px" objectFit="contain" />} + variant="outline" + colorScheme="red" + size="sm" + onClick={onClick} + isLoading={isLoading} + isDisabled={!product.price} + /> + )} + </AddToCart> + </Box> + + {/* 2. TOMBOL BELI SEKARANG */} + {/* Bungkus dengan Box flex={1} agar mengisi sisa ruang (Sesuai kode lama) */} + <Box flex={1}> + <AddToCart + source="buy" + products={productPayload} + variantId={product.id} + quantity={1} + > + {({ onClick, isLoading }) => ( + <Button + colorScheme="red" + size="sm" + fontSize="xs" + w="100%" // Paksa lebar 100% mengikuti parent Box + onClick={onClick} + isLoading={isLoading} + isDisabled={!product.price} + > + Beli Sekarang + </Button> + )} + </AddToCart> + </Box> + + </HStack> + </VStack> + ); + } else { + // TAMPILAN KOSONG + content = ( + <VStack align="stretch" spacing={3} h="100%" position="relative"> + {index !== 0 && products.length > 2 && ( + <IconButton + aria-label="Hapus Kolom" icon={<X size={16} />} + size="xs" position="absolute" top={-2} right={-2} + colorScheme="gray" variant="solid" borderRadius="full" zIndex={2} + onClick={() => handleRemoveProduct(index)} + /> + )} + + <Box position="relative" w="100%" ref={activeSearchSlot === index ? searchWrapperRef : null}> + <InputGroup size="sm"> + <InputLeftElement pointerEvents="none"><Icon as={Search} color="gray.300" /></InputLeftElement> + <Input + placeholder="Cari Produk..." + borderRadius="md" + fontSize="16px" + sx={{ + base: { + transform: "scale(0.875)", // Kecilkan visual jadi setara 14px (sm) + transformOrigin: "left center", + width: "114.29%", // Kompensasi lebar + marginBottom: "-2px", + marginRight: "-14.29%" + }, + md: { + fontSize: "sm", // Di Desktop pakai ukuran asli (14px) + transform: "none", + width: "100%", + marginBottom: "0", + marginRight: "0" + } + }} + value={activeSearchSlot === index ? searchQuery : ''} + onFocus={() => { setActiveSearchSlot(index); setSearchQuery(''); }} + onChange={(e) => setSearchQuery(e.target.value)} + /> + {activeSearchSlot === index && searchQuery && ( + <InputRightElement cursor="pointer" onClick={() => { setSearchQuery(''); setActiveSearchSlot(null); }}> + <Icon as={X} color="gray.400" size={14}/> + </InputRightElement> + )} + </InputGroup> + + {activeSearchSlot === index && ( + <Box position="absolute" top="35px" left={0} right={0} bg="white" boxShadow="lg" zIndex={10} borderRadius="md" border="1px solid" borderColor="gray.200" maxH="250px" overflowY="auto"> + {!selectedVariant?.attribute_set_id && !mainProduct?.attribute_set_id ? ( + <Box p={4} fontSize="xs" color="orange.600" textAlign="center" bg="orange.50"> + <Text fontWeight="bold" mb={1}>Perbandingan Tidak Tersedia</Text> + <Text>Produk utama tidak memiliki data kategori yang valid untuk dibandingkan.</Text> + </Box> + ) : ( + <> + {isSearching ? ( + <Box p={4} textAlign="center"><Spinner size="sm" color="red.500" /></Box> + ) : searchResults.length > 0 ? ( + <List spacing={0}> + {searchResults.map((res) => ( + <ListItem + key={res.id} + p={3} + borderBottom="1px solid #f0f0f0" + _hover={{ bg: 'red.50', cursor: 'pointer' }} + onClick={() => handleAddProduct(res, index)} + > + <Flex align="flex-start" gap={3}> + <Image + src={res.image || '/images/no-image-compare.svg'} + boxSize="40px" + objectFit="contain" + onError={(e) => { (e.target as HTMLImageElement).src = '/images/no-image-compare.svg'; }} + flexShrink={0} + mt={1} + /> + <Box flex={1} w="0"> + <Text fontSize="xs" fontWeight="bold" noOfLines={2} lineHeight="shorter" whiteSpace="normal" mb={1} title={res.displayName || res.name}> + {res.displayName || res.name} + </Text> + <Text fontSize="xs" color="red.500" fontWeight="bold"> + {formatPrice(res.lowestPrice?.price || 0)} + </Text> + </Box> + </Flex> + </ListItem> + ))} + </List> + ) : ( + <Box p={3} fontSize="xs" color="gray.500" textAlign="center"> + {searchQuery === '' ? 'Menampilkan rekomendasi...' : 'Produk tidak ditemukan.'} + </Box> + )} + </> + )} + </Box> + )} + </Box> + + <Flex + direction="column" + align="center" + justify="center" + flex={1} + bg="white" + borderRadius="md" + > + <Image + src="/images/no-image-compare.svg" + alt="Empty Slot" + boxSize="125px" + mb={2} + opacity={0.6} + /> + <Text fontSize="xs" color="gray.500" textAlign="center"> + Produk Belum Ditambahkan + </Text> + </Flex> + </VStack> + ); + } + + // Animasi Wrapper ScaleFade + return ( + <ScaleFade initialScale={0.9} in={true}> + {content} + </ScaleFade> + ); + }; + + // --- RENDER MOBILE CONTENT --- + const renderMobileContent = () => { + const mobileProducts = products.slice(0, 2); + + return ( + <Box pb={6}> + {/* Sticky Header */} + <Box + position="sticky" top="-16px" zIndex={10} + bg="white" pt={4} pb={2} mx="-16px" px="16px" + borderBottom="1px solid" borderColor="gray.100" shadow="sm" + > + <Grid templateColumns="1fr 1fr" gap={4} mb={4}> + {mobileProducts.map((p, i) => ( + <GridItem key={i} position="relative"> + {renderProductSlot(p, i)} + </GridItem> + ))} + </Grid> + + <Flex justify="center" align="center" gap={2}> + <Text fontSize="md" fontWeight="bold" color="gray.700"> + Spesifikasi Teknis + </Text> + </Flex> + </Box> + + {/* Specs List with Loader per Line */} + <Box mt={4}> + {specsMatrix.length > 0 ? ( + <VStack spacing={0} align="stretch" divider={<Divider />}> + {specsMatrix.map((row, rIdx) => ( + <Box key={rIdx} py={4}> + <Grid templateColumns="1fr 1fr" gap={4}> + {mobileProducts.map((p, cIdx) => { + const val = p ? (row.values[String(p.sku)] || '-') : '-'; + const isItemLoading = isLoadingMatrix && p && !row.values[String(p.sku)]; + + return ( + <VStack key={cIdx} spacing={1} align="center"> + {isItemLoading ? ( + <Spinner size="xs" color="red.500" /> + ) : ( + <Box + fontSize="12px" + color="gray.800" + w="100%" + textAlign="center" + sx={{ + 'ul, ol': { + textAlign: 'left', + listStylePosition: 'outside !important', + paddingLeft: '1.2em !important', + marginLeft: '0 !important', + marginTop: '0.5em !important', + marginBottom: '1em !important' + }, + 'ul': { listStyleType: 'disc !important' }, + 'ol': { listStyleType: 'decimal !important' }, + + // 2. ITEM LIST (LI) + 'li': { + textAlign: 'left', + marginBottom: '4px !important', + paddingLeft: '0.2em !important', + lineHeight: '1.5 !important', + fontWeight: 'normal !important' + }, + + 'strong, b': { + display: 'block !important', + fontWeight: '700 !important', + marginBottom: '0px !important', + marginTop: '4px !important', + color: '#1a202c', + textAlign: 'left' + }, + + 'p': { + margin: '0 !important', + padding: '0 !important' + } + }} + dangerouslySetInnerHTML={{ __html: renderSpecValue(val) }} + /> + )} + <Text fontSize="10px" color="gray.600" fontWeight="bold" textAlign="center"> + {row.label} + </Text> + </VStack> + ) + })} + </Grid> + </Box> + ))} + </VStack> + ) : ( + <Box textAlign="center" py={10} color="gray.500"> + {isLoadingMatrix ? <VStack><Spinner color="red.500" /><Text fontSize="xs">Memuat data...</Text></VStack> : "Data spesifikasi tidak tersedia"} + </Box> + )} + </Box> + </Box> + ); + }; + + // --- MAIN RENDER --- + + if (isMobile) { + return ( + <Drawer isOpen={isOpen} placement="bottom" onClose={onClose}> + <DrawerOverlay /> + <DrawerContent borderTopRadius="20px" h="88vh" bg="white"> + <DrawerCloseButton zIndex={20} /> + <DrawerHeader borderBottomWidth="1px" fontSize="md" textAlign="center">Bandingkan Produk</DrawerHeader> + <DrawerBody + p={4} + overflowY="auto" + css={{ + '&::-webkit-scrollbar': { width: '4px' }, + '&::-webkit-scrollbar-track': { width: '6px' }, + '&::-webkit-scrollbar-thumb': { background: '#cbd5e0', borderRadius: '24px' }, + }} + > + {renderMobileContent()} + </DrawerBody> + </DrawerContent> + </Drawer> + ); + } + + // Tampilan Desktop (Modal 6XL) - DYNAMIC GRID + const totalColumns = 1 + products.length + (products.length < 4 ? 1 : 0); + const productColumnsCount = products.length + (products.length < 4 ? 1 : 0); + + const SLOT_WIDTH_PX = 200; + const LABEL_WIDTH_PX = 200; + const GAP_PX = 16; + const PADDING_PX = 48; + + const calculatedWidth = LABEL_WIDTH_PX + (productColumnsCount * SLOT_WIDTH_PX) + (productColumnsCount * GAP_PX) + PADDING_PX + 'px'; + + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + scrollBehavior="inside" + isCentered + > + <ModalOverlay /> + + <ModalContent + height="90vh" + maxW="95vw" + w={calculatedWidth} + transition="width 0.4s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.4s ease" + > + <ModalHeader borderBottom="1px solid #eee" pb={2}> + <HStack spacing={3}> + <Text fontSize="xl" fontWeight="bold">Bandingkan Produk</Text> + <Badge colorScheme="red" variant="solid" borderRadius="full" px={2} textTransform="none"> + Baru + </Badge> + </HStack> + <Text fontSize="sm" color="gray.500" fontWeight="normal" mt={1}> + Detail Spesifikasi Produk yang kamu pilih + </Text> + </ModalHeader> + <ModalCloseButton /> + + <ModalBody p={6} bg="white" overflowX="auto"> + <Grid + templateColumns={`${LABEL_WIDTH_PX}px repeat(${productColumnsCount}, ${SLOT_WIDTH_PX}px)`} + gap={4} + // [TAMBAH] Animasi Transisi Grid + transition="all 0.4s ease" + > + + <GridItem /> + {products.map((product, index) => ( + <GridItem key={index} position="relative" minW="0"> + {renderProductSlot(product, index)} + </GridItem> + ))} + + {/* Render Tombol Tambah Slot (Jika slot < 4) */} + {products.length < 4 && ( + <GridItem display="flex" alignItems="center" justifyContent="center"> + {/* [TAMBAH] Animasi Wrapper ScaleFade untuk Tombol */} + <ScaleFade initialScale={0.9} in={true} style={{ width: '100%', height: '100%' }}> + <Button + onClick={handleAddSlot} + variant="outline" + border="2px dashed" + borderColor="gray.300" + color="gray.500" + h="100%" + w="100%" + flexDirection="column" + gap={2} + _hover={{ bg: 'gray.50', borderColor: 'gray.400' }} + > + <Box bg="gray.100" p={2} borderRadius="full"> + <Plus size={24} /> + </Box> + <Text fontSize="sm">Tambah Produk</Text> + <Text fontSize="9px" fontWeight="normal">Bandingkan hingga 4 produk</Text> + </Button> + </ScaleFade> + </GridItem> + )} + + <GridItem colSpan={1 + productColumnsCount} py={6} display="flex" alignItems="center" justifyContent="space-between"> + <Box borderBottom="2px solid" borderColor="gray.100" pb={2} width="100%"> + <HStack> + <Text fontSize="lg" fontWeight="bold">Spesifikasi Teknis</Text> + {isLoadingMatrix && specsMatrix.length > 0 && ( + <HStack spacing={2}> + <Spinner size="xs" color="red.500" /> + <Text fontSize="xs" color="gray.500">Updating...</Text> + </HStack> + )} + </HStack> + </Box> + </GridItem> + + {isLoadingMatrix && specsMatrix.length === 0 ? ( + <GridItem colSpan={1 + productColumnsCount} textAlign="center" py={10}> + <Spinner color="red.500" thickness="4px" size="xl" /> + <Text mt={2} color="gray.500">Memuat data...</Text> + </GridItem> + ) : specsMatrix.length > 0 ? ( + specsMatrix.map((row, rowIndex) => ( + <React.Fragment key={row.code || rowIndex}> + <GridItem + py={3} px={2} + borderBottom="1px solid" borderColor="gray.100" + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + display="flex" alignItems="center" + opacity={isLoadingMatrix ? 0.6 : 1} + transition="opacity 0.2s" + > + <Text fontWeight="bold" fontSize="sm" color="gray.700">{row.label}</Text> + </GridItem> + + {products.map((product, colIndex) => { + const val = product ? (row.values[String(product.sku)] || '-') : ''; + return ( + <GridItem + key={`${row.code}-${colIndex}`} + py={3} px={2} + borderBottom="1px solid" borderColor="gray.100" + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + display="flex" + alignItems="center" + justifyContent="center" + textAlign="center" + opacity={isLoadingMatrix ? 0.6 : 1} + transition="opacity 0.2s" + > + {isLoadingMatrix && product && !row.values[String(product.sku)] ? ( + <Spinner size="xs" color="gray.400" /> + ) : ( + <Box + fontSize="sm" + color="gray.600" + w="100%" + sx={{ + '& ul, & ol': { + textAlign: 'left', // List Wajib Rata Kiri + listStylePosition: 'outside !important', + paddingLeft: '1.2em !important', + marginLeft: '0 !important', + marginTop: '0.5em !important', + marginBottom: '1em !important' + }, + 'ul': { listStyleType: 'disc !important' }, + 'ol': { listStyleType: 'decimal !important' }, + '& li': { + textAlign: 'left', + marginBottom: '4px !important', + paddingLeft: '0.2em !important', + lineHeight: '1.5 !important', + fontWeight: 'normal !important' + }, + '& strong': { + display: 'block !important', + fontWeight: '700 !important', + marginBottom: '0px !important', + marginTop: '4px !important', + color: '#1a202c', + textAlign: 'left' + }, + '& p': { + margin: '0 !important', + padding: '0 !important' + } + }} + dangerouslySetInnerHTML={{ __html: renderSpecValue(val) }} + /> + )} + </GridItem> + ); + })} + {products.length < 4 && ( + <GridItem + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + borderBottom="1px solid" + borderColor="gray.100" + /> + )} + </React.Fragment> + )) + ) : ( + <GridItem colSpan={1 + productColumnsCount} py={10} textAlign="center" color="gray.500" bg="gray.50"> + <Text>Data spesifikasi belum tersedia untuk produk ini.</Text> + </GridItem> + )} + </Grid> + </ModalBody> + </ModalContent> + </Modal> + ); +}; + +export default ProductComparisonModal;
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 129ca8de..de205c41 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -2,15 +2,37 @@ import style from '../styles/product-detail.module.css'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useRef, useState, UIEvent } from 'react'; +import { useEffect, useRef, useState, UIEvent, useMemo } from 'react'; -import { Button } from '@chakra-ui/react'; +// Import komponen Chakra UI +import { + Button, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Table, + Tbody, + Tr, + Td, + Th, + Thead, + Box, + Spinner, + Center, + Text, + Stack, +} from '@chakra-ui/react'; + +// Import Icons import { - AlertCircle, AlertTriangle, MessageCircleIcon, Share2Icon, + ExternalLink, } from 'lucide-react'; + import { LazyLoadComponent } from 'react-lazy-load-image-component'; import useDevice from '@/core/hooks/useDevice'; @@ -28,7 +50,11 @@ import SimilarBottom from './SimilarBottom'; import SimilarSide from './SimilarSide'; import dynamic from 'next/dynamic'; +// 1. IMPORT MODAL (Baru) +import ProductComparisonModal from './ProductComparisonModal'; + import { gtagProductDetail } from '@/core/utils/googleTag'; +import Skeleton from 'react-loading-skeleton'; type Props = { product: IProductDetail; @@ -36,15 +62,66 @@ type Props = { const RWebShare = dynamic( () => import('react-web-share').then((m) => m.RWebShare), - { ssr: false } + { ssr: false }, ); +// 1. STYLE DESKTOP (Tebal, Jelas, dengan Border/Padding) +const cssScrollbarDesktop = { + '&::-webkit-scrollbar': { + width: '10px', + height: '10px', + }, + '&::-webkit-scrollbar-track': { + background: '#f1f1f1', + borderRadius: '4px', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: '#9ca3af', // Gray-400 + borderRadius: '6px', + border: '2px solid #f1f1f1', // Efek padding + }, + '&::-webkit-scrollbar-thumb:hover': { + backgroundColor: '#6b7280', + }, +}; + +// 2. STYLE MOBILE (Tipis, Minimalis, Tanpa Border) +const cssScrollbarMobile = { + '&::-webkit-scrollbar': { + width: '3px', // Sangat tipis vertikal + height: '3px', // Sangat tipis horizontal + }, + '&::-webkit-scrollbar-track': { + background: 'transparent', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: '#cbd5e1', // Gray-300 + borderRadius: '3px', + }, +}; + const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST; const ProductDetail = ({ product }: Props) => { const { isDesktop, isMobile } = useDevice(); const router = useRouter(); const [auth, setAuth] = useState<any>(null); + + // console.log('Render ProductDetail for product ID:', product); + + // State Data dari Magento + const [specsMatrix, setSpecsMatrix] = useState<any[]>([]); + const [upsellIds, setUpsellIds] = useState<number[]>([]); + const [relatedIds, setRelatedIds] = useState<number[]>([]); + const [descriptionMap, setDescriptionMap] = useState<Record<string, string>>( + {}, + ); + + const [loadingSpecs, setLoadingSpecs] = useState(false); + + // 2. STATE MODAL COMPARE (Baru) + const [isCompareOpen, setCompareOpen] = useState(false); + useEffect(() => { try { setAuth(getAuth() ?? null); @@ -61,8 +138,8 @@ const ProductDetail = ({ product }: Props) => { activeVariantId, setIsApproval, isApproval, + selectedVariant, setSelectedVariant, - setSla, } = useProductDetail(); useEffect(() => { @@ -95,15 +172,178 @@ const ProductDetail = ({ product }: Props) => { // }); // }, [product?.id]); + // 1. LOGIC INISIALISASI VARIANT useEffect(() => { if (typeof auth === 'object') { setIsApproval(auth?.feature?.soApproval); } - const selectedVariant = + const variantInit = product?.variants?.find((variant) => variant.is_in_bu) || product?.variants?.[0]; - setSelectedVariant(selectedVariant); - }, []); + + setSelectedVariant(variantInit); + + setSpecsMatrix([]); + setUpsellIds([]); + setRelatedIds([]); + }, [product, auth]); + + // 2. LOGIC FETCH DATA + useEffect(() => { + const fetchMagentoData = async () => { + const allVariantIds = product.variants.map((v) => v.id); + + if (allVariantIds.length === 0) return; + + const mainId = allVariantIds[0]; + + setLoadingSpecs(true); + + try { + const params = new URLSearchParams({ + skus: allVariantIds.join(','), + main_sku: String(mainId), + }); + + const endpoint = `/api/magento-product?${params.toString()}`; + + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + setSpecsMatrix([]); + setUpsellIds([]); + setRelatedIds([]); + return; + } + + const data = await response.json(); + + // 1. Specs Matrix (Processed Grouping) + if (data.specsMatrix && Array.isArray(data.specsMatrix)) { + // const filteredMatrix = data.specsMatrix.filter((item: any) => { + // const code = item.code || ''; + // return !code.includes('z_brand'); + // }); + const processed = processMatrixData(data.specsMatrix); + setSpecsMatrix(processed); + // const processed = processMatrixData(filteredMatrix); + // setSpecsMatrix(processed); + } else { + setSpecsMatrix([]); + } + + if (data.descriptions) { + setDescriptionMap(data.descriptions); + } + + // 2. Upsell & Related + if (data.upsell_ids && Array.isArray(data.upsell_ids)) + setUpsellIds(data.upsell_ids); + else setUpsellIds([]); + + if (data.related_ids && Array.isArray(data.related_ids)) + setRelatedIds(data.related_ids); + else setRelatedIds([]); + } catch (error) { + console.error('Gagal mengambil data Magento:', error); + setSpecsMatrix([]); + } finally { + setLoadingSpecs(false); + } + }; + + fetchMagentoData(); + }, [product.id]); + + // HELPER 1: GROUPING DATA BY LABEL + const processMatrixData = (rawMatrix: any[]) => { + const groups: any = {}; + const result: any[] = []; + + rawMatrix.forEach((item) => { + if (item.label && item.label.includes(' : ')) { + const parts = item.label.split(' : '); + const groupName = parts[0].trim(); + const childLabel = parts.slice(1).join(' : ').trim(); + + if (!groups[groupName]) { + groups[groupName] = { + type: 'group', + label: groupName, + children: [], + }; + result.push(groups[groupName]); + } + + groups[groupName].children.push({ + ...item, + label: childLabel, + }); + } else { + result.push({ ...item, type: 'single' }); + } + }); + + return result; + }; + + // HELPER 2: RENDER SPEC VALUE + const renderSpecValue = (val: any) => { + if (!val) return '-'; + const strVal = String(val).trim(); + + const isUrl = + !strVal.includes(' ') && + (strVal.startsWith('http') || strVal.startsWith('www.')); + if (isUrl) { + const href = strVal.startsWith('http') ? strVal : `https://${strVal}`; + return ( + <a + href={href} + target='_blank' + rel='noopener noreferrer' + className='text-red-600 hover:underline inline-flex items-center gap-1' + > + <ExternalLink size={14} /> Link + </a> + ); + } + + if (strVal.includes('<') && strVal.includes('>')) { + return ( + <Box + className='prose prose-sm text-gray-700' + sx={{ + '& ul, & ol': { + paddingLeft: '1.2rem', + margin: 0, + textAlign: 'left', + }, + '& li': { + fontWeight: 'normal', + marginBottom: '4px', + textAlign: 'left', + }, + '& strong': { + display: 'block', + marginBottom: '2px', + fontWeight: 'bold', + }, + '& p': { + margin: 0, + textAlign: 'left', + }, + }} + dangerouslySetInnerHTML={{ __html: strVal }} + /> + ); + } + + return strVal; + }; const allImages = (() => { const arr: string[] = []; @@ -153,8 +393,62 @@ const ProductDetail = ({ product }: Props) => { setMainImage(allImages[i] || ''); }; + const sortedVariants = useMemo(() => { + if (!product?.variants) return []; + + return [...product.variants].sort((a, b) => { + const labelA = + a.attributes && a.attributes.length > 0 + ? a.attributes.join(' - ') + : a.code || ''; + + const labelB = + b.attributes && b.attributes.length > 0 + ? b.attributes.join(' - ') + : b.code || ''; + + const getNumber = (str: string) => { + const match = String(str).match(/(\d+(\.\d+)?)/); + return match ? parseFloat(match[0]) : null; + }; + + const numA = getNumber(labelA); + const numB = getNumber(labelB); + + if (numA !== null && numB !== null && numA !== numB) { + return numA - numB; + } + + return String(labelA).localeCompare(String(labelB), undefined, { + numeric: true, + sensitivity: 'base', + }); + }); + }, [product.variants]); + + const activeMagentoDesc = selectedVariant?.id + ? descriptionMap[String(selectedVariant.id)] + : ''; + const finalDescription = + activeMagentoDesc || + product.description || + 'Deskripsi produk tidak tersedia.'; + const cleanDescription = + finalDescription === '<p><br></p>' + ? 'Deskripsi produk tidak tersedia.' + : finalDescription; + return ( <> + {/* 3. MODAL POPUP DIRENDER DISINI */} + {/* Render di luar layout utama agar tidak tertutup elemen lain */} + <ProductComparisonModal + isOpen={isCompareOpen} + onClose={() => setCompareOpen(false)} + mainProduct={product} + selectedVariant={selectedVariant} + /> + <div className='relative'> {isDesktop && !hasPrice && ( <div className='absolute inset-0 z-[20] flex items-center justify-center pointer-events-none select-none'> @@ -188,7 +482,7 @@ const ProductDetail = ({ product }: Props) => { <div className='md:flex md:flex-wrap'> {/* ===== Kolom kiri: gambar ===== */} <div className='md:w-4/12'> - {/* === MOBILE: Slider swipeable, tanpa thumbnail carousel === */} + {/* ... Image Slider ... */} {isMobile ? ( <div className='relative'> <div @@ -207,7 +501,6 @@ const ProductDetail = ({ product }: Props) => { key={i} className='w-full flex-shrink-0 snap-center flex justify-center items-center' > - {/* gambar diperkecil */} <img src={img} alt={`Gambar ${i + 1}`} @@ -229,17 +522,13 @@ const ProductDetail = ({ product }: Props) => { </div> )} </div> - - {/* Dots indicator */} {allImages.length > 1 && ( <div className='absolute bottom-2 left-0 right-0 flex justify-center gap-2'> {allImages.map((_, i) => ( <button key={i} aria-label={`Ke slide ${i + 1}`} - className={`w-2 h-2 rounded-full ${ - currentIdx === i ? 'bg-gray-800' : 'bg-gray-300' - }`} + className={`w-2 h-2 rounded-full ${currentIdx === i ? 'bg-gray-800' : 'bg-gray-300'}`} onClick={() => scrollToIndex(i)} /> ))} @@ -248,21 +537,14 @@ const ProductDetail = ({ product }: Props) => { </div> ) : ( <> - {/* === DESKTOP: Tetap seperti sebelumnya === */} <ProductImage product={{ ...product, image: mainImage }} /> - - {/* Carousel horizontal (thumbnail) – hanya desktop */} {allImages.length > 0 && ( <div className='mt-4 overflow-x-auto'> <div className='flex space-x-3 pb-3'> {allImages.map((img, index) => ( <div key={index} - className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${ - mainImage === img - ? 'border-red-500 ring-2 ring-red-200' - : 'border-gray-200 hover:border-gray-300' - }`} + className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${mainImage === img ? 'border-red-500 ring-2 ring-red-200' : 'border-gray-200 hover:border-gray-300'}`} onClick={() => setMainImage(img)} > <img @@ -283,7 +565,6 @@ const ProductDetail = ({ product }: Props) => { </> )} </div> - {/* <<=== TUTUP kolom kiri */} {/* ===== Kolom kanan: info ===== */} {isDesktop && ( @@ -294,9 +575,9 @@ const ProductDetail = ({ product }: Props) => { size={18} className='text-red-600 shrink-0 mx-2' /> - <div className='text-red-600 font-normal text-h-sm p-2'> + <h1 className='text-red-600 font-normal text-h-sm'> Maaf untuk saat ini Produk yang anda cari tidak tersedia - </div> + </h1> </div> )} <div className='h-6 md:h-0' /> @@ -314,27 +595,27 @@ const ProductDetail = ({ product }: Props) => { size={18} className='text-red-600 shrink-0 mx-2' /> - <div className='text-red-600 font-normal text-h-sm p-2'> + <h1 className='text-red-600 font-normal text-h-sm'> Maaf untuk saat ini Produk yang anda cari tidak tersedia - </div> + </h1> </div> )} <h1 className={style['title']}>{product.name}</h1> <div className='h-3 md:h-0' /> <Information product={product} /> - <div className='h-6' /> + <div className='h-2' /> </div> )} </div> <div className='h-full'> {isMobile && ( - <div className='px-4 pt-6'> + <div className='px-4 pt-2'> <PriceAction product={product} /> </div> )} - <div className='h-4 md:h-10' /> + <div className='h-2 md:h-10' /> {!!activeVariantId && !isApproval && ( <ProductPromoSection product={product} @@ -344,29 +625,337 @@ const ProductDetail = ({ product }: Props) => { <div className='h-0 md:h-6' /> + {/* === SECTION TABS: DESKRIPSI & SPESIFIKASI === */} <div className={style['section-card']}> - <h2 className={style['heading']}>Informasi Produk</h2> - <div className='h-4' /> - <div className='overflow-x-auto'> - <div - className={style['description']} - dangerouslySetInnerHTML={{ - __html: - !product.description || - product.description == '<p><br></p>' - ? 'Belum ada deskripsi' - : product.description, - }} - /> - </div> + <Tabs variant='unstyled'> + <TabList borderBottom='1px solid' borderColor='gray.200'> + <Tab + _selected={{ + color: 'red.600', + borderColor: 'red.600', + borderBottomWidth: '3px', + fontWeight: 'bold', + marginBottom: '-1.5px', + }} + color='gray.500' + fontWeight='medium' + fontSize='sm' + px={4} + py={3} + > + Deskripsi + </Tab> + <Tab + _selected={{ + color: 'red.600', + borderColor: 'red.600', + borderBottomWidth: '3px', + fontWeight: 'bold', + marginBottom: '-1.5px', + }} + color='gray.500' + fontWeight='medium' + fontSize='sm' + px={4} + py={3} + > + Spesifikasi + </Tab> + {/* <Tab + _selected={{ color: 'red.600', borderColor: 'red.600', borderBottomWidth: '3px', fontWeight: 'bold', marginBottom: '-1.5px' }} + color="gray.500" fontWeight="medium" fontSize="sm" px={4} py={3} + > + Detail Lainnya + </Tab> */} + </TabList> + + <TabPanels> + {/* DESKRIPSI */} + <TabPanel px={0} py={6}> + <div className='overflow-x-auto text-sm text-gray-700'> + {loadingSpecs ? ( + <Stack spacing={4}> + <Skeleton height='20px' width='100%' /> + <Skeleton height='20px' width='90%' /> + <Skeleton height='20px' width='95%' /> + <Skeleton height='20px' width='70%' /> + </Stack> + ) : ( + <Box + className={style['description']} + sx={{ + 'ul, ol': { + marginTop: '0.5em !important', + marginBottom: '1em !important', + marginLeft: '0 !important', + listStylePosition: 'outside !important', + paddingLeft: '1.5em !important', + }, + ul: { listStyleType: 'disc !important' }, + ol: { listStyleType: 'decimal !important' }, + li: { + marginBottom: '0.4em !important', + paddingLeft: '0.3em !important', + lineHeight: '1.6 !important', + }, + }} + dangerouslySetInnerHTML={{ __html: cleanDescription }} + /> + )} + </div> + </TabPanel> + + {/* SPESIFIKASI */} + <TabPanel px={0} py={2}> + <Box + border='1px solid' + borderColor='gray.200' + borderRadius='sm' + overflowX='auto' + overflowY='auto' + maxHeight='500px' + css={isMobile ? cssScrollbarMobile : cssScrollbarDesktop} + > + {loadingSpecs ? ( + <Center py={6}> + <Spinner color='red.500' /> + </Center> + ) : specsMatrix.length > 0 ? ( + (() => { + const variantCount = sortedVariants.length; + const isSingleVariant = variantCount === 1; + + // === LOGIC 1: SINGLE VARIANT (VERTICAL TABLE) === + if (isSingleVariant) { + const singleVariantId = sortedVariants[0].id; + // Flatten data untuk list vertical + const rows: any[] = []; + specsMatrix.forEach((row) => { + if (row.type === 'group') { + row.children.forEach((child: any) => + rows.push(child), + ); + } else { + rows.push(row); + } + }); + + return ( + <Table + variant='simple' + size={isMobile ? 'sm' : 'md'} + > + <Tbody> + {rows.map((row, idx) => ( + <Tr + key={idx} + bg={idx % 2 === 0 ? 'white' : 'gray.50'} + > + {/* Kolom Label (Kiri) */} + <Td + width='40%' + fontWeight='bold' + color='gray.600' + borderColor='gray.200' + verticalAlign='top' + py={3} + > + {row.label} + </Td> + {/* Kolom Value (Kanan) */} + <Td + color='gray.800' + borderColor='gray.200' + verticalAlign='top' + py={3} + > + {renderSpecValue( + row.values[singleVariantId], + )} + </Td> + </Tr> + ))} + </Tbody> + </Table> + ); + } + + // === LOGIC 2: MULTIPLE VARIANTS (MATRIX TABLE HORIZONTAL) === + const topHeaders: any[] = []; + const subHeaders: any[] = []; + const flatSpecs: any[] = []; + + specsMatrix.forEach((row) => { + if (row.type === 'group') { + topHeaders.push({ + label: row.label, + type: 'group', + colSpan: row.children.length, + rowSpan: 1, + }); + row.children.forEach((child: any) => { + subHeaders.push(child); + flatSpecs.push(child); + }); + } else { + topHeaders.push({ + label: row.label, + type: 'single', + colSpan: 1, + rowSpan: 2, + }); + flatSpecs.push(row); + } + }); + + return ( + <Table + variant='simple' + size={isMobile ? 'sm' : 'md'} + > + <Thead + bg='red.600' + position='sticky' + top={0} + zIndex={3} + > + <Tr> + {topHeaders.map((th, idx) => ( + <Th + key={`top-${idx}`} + position={idx === 0 ? 'sticky' : 'static'} + left={idx === 0 ? 0 : undefined} + zIndex={idx === 0 ? 4 : 3} + boxShadow={ + idx === 0 + ? '2px 0 5px -2px rgba(0,0,0,0.2)' + : 'none' + } + bg='red.600' + colSpan={th.colSpan} + rowSpan={th.rowSpan} + color='white' + textAlign='center' + fontSize={isMobile ? 'xs' : 'sm'} + textTransform='none' + fontWeight='800' + letterSpacing='wide' + verticalAlign='middle' + borderBottom='none' + px={isMobile ? 2 : 4} + > + {th.label} + </Th> + ))} + </Tr> + <Tr> + {subHeaders.map((sub, idx) => { + const isFirstHeaderGroup = + topHeaders[0]?.type === 'group'; + const shouldSticky = + idx === 0 && isFirstHeaderGroup; + return ( + <Th + key={`sub-${idx}`} + position={ + shouldSticky ? 'sticky' : 'static' + } + left={shouldSticky ? 0 : undefined} + zIndex={shouldSticky ? 4 : 1} + boxShadow={ + shouldSticky + ? '2px 0 5px -2px rgba(0,0,0,0.2)' + : 'none' + } + color='white' + textAlign='center' + fontSize='xs' + textTransform='none' + verticalAlign='middle' + whiteSpace='nowrap' + bg='red.600' + pt={1} + pb={1} + px={isMobile ? 2 : 4} + > + {sub.label} + </Th> + ); + })} + </Tr> + </Thead> + + <Tbody> + {sortedVariants.map((v, vIdx) => ( + <Tr + key={v.id} + bg={vIdx % 2 === 0 ? 'white' : 'gray.50'} + > + {flatSpecs.map((spec, sIdx) => { + const rawValue = spec.values[v.id] || '-'; + const isFirstCol = sIdx === 0; + return ( + <Td + key={sIdx} + position={ + isFirstCol ? 'sticky' : 'static' + } + left={isFirstCol ? 0 : undefined} + zIndex={isFirstCol ? 2 : 1} + bg={ + vIdx % 2 === 0 ? 'white' : 'gray.50' + } + boxShadow={ + isFirstCol + ? '2px 0 5px -2px rgba(0,0,0,0.1)' + : 'none' + } + borderColor='gray.200' + textAlign='center' + fontSize={isMobile ? 'xs' : 'sm'} + verticalAlign='middle' + px={isMobile ? 1 : 2} + py={3} + minW={isMobile ? '100px' : '120px'} + maxW='200px' + whiteSpace='normal' + overflowWrap='break-word' + fontWeight={ + isFirstCol ? 'bold' : 'normal' + } + > + {renderSpecValue(rawValue)} + </Td> + ); + })} + </Tr> + ))} + </Tbody> + </Table> + ); + })() + ) : ( + <Box p={4} color='gray.500' fontSize='sm'> + <Text>Spesifikasi teknis belum tersedia.</Text> + </Box> + )} + </Box> + </TabPanel> + </TabPanels> + </Tabs> </div> </div> </div> {isDesktop && ( <div className='md:w-3/12'> - <PriceAction product={product} /> - <div className='flex gap-x-5 items-center justify-center'> + {/* 4. INTEGRASI: PASSING HANDLER MODAL KE PRICE ACTION */} + <PriceAction + product={product} + onCompare={() => setCompareOpen(true)} + /> + + <div className='flex gap-x-5 items-center justify-center py-4'> <Button as={Link} href={askAdminUrl} @@ -378,15 +967,11 @@ const ProductDetail = ({ product }: Props) => { > Ask Admin </Button> - <span>|</span> - <div className={hasPrice ? '' : 'opacity-40 pointer-events-none'}> <AddToWishlist productId={product.id} /> </div> - <span>|</span> - {canShare && ( <RWebShare data={{ @@ -411,23 +996,18 @@ const ProductDetail = ({ product }: Props) => { <div className='h-6' /> <div className={style['heading']}>Produk Serupa</div> - <div className='h-4' /> - - <SimilarSide product={product} /> + <SimilarSide product={product} relatedIds={relatedIds} /> </div> )} <div className='md:w-full pt-4 md:py-10 px-4 md:px-0'> <div className={style['heading']}>Kamu Mungkin Juga Suka</div> - <div className='h-6' /> - <LazyLoadComponent> - <SimilarBottom product={product} /> + <SimilarBottom product={product} upsellIds={upsellIds} /> </LazyLoadComponent> </div> - <div className='h-6 md:h-0' /> </div> </> diff --git a/src-migrate/modules/product-detail/components/SimilarBottom.tsx b/src-migrate/modules/product-detail/components/SimilarBottom.tsx index 40d4dd82..d3957f4b 100644 --- a/src-migrate/modules/product-detail/components/SimilarBottom.tsx +++ b/src-migrate/modules/product-detail/components/SimilarBottom.tsx @@ -1,23 +1,58 @@ import { Skeleton } from '@chakra-ui/react' -import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar' +import { useQuery } from 'react-query' import ProductSlider from '~/modules/product-slider' +import { getProductSimilar, getProductsByIds } from '~/services/product' import { IProductDetail } from '~/types/product' type Props = { - product: IProductDetail + product: IProductDetail; + upsellIds?: number[]; } -const SimilarBottom = ({ product }: Props) => { - const productSimilar = useProductSimilar({ - name: product.name, - except: { productId: product.id } - }) +const SimilarBottom = ({ product, upsellIds = [] }: Props) => { + + const hasUpsell = upsellIds.length > 0; - const products = productSimilar.data?.products || [] + // Query 1: Upsell + const upsellQuery = useQuery({ + queryKey: ['product-upsell', upsellIds], + queryFn: () => getProductsByIds({ ids: upsellIds }), + enabled: hasUpsell, + staleTime: 1000 * 60 * 5, + }); + + // Query 2: Similar Biasa + const similarQuery = useQuery({ + queryKey: ['product-similar', product.name], + queryFn: () => getProductSimilar({ + name: product.name, + except: { productId: product.id } + }), + enabled: !hasUpsell, + staleTime: 1000 * 60 * 5, + }); + + let products = []; + let isLoading = false; + + // ========================================== + // PERBAIKAN DI SINI + // ========================================== + if (hasUpsell) { + // Salah: products = upsellQuery.data || []; + // Benar: Ambil properti .products di dalamnya + products = (upsellQuery.data as any)?.products || []; + isLoading = upsellQuery.isLoading; + } else { + products = similarQuery.data?.products || []; + isLoading = similarQuery.isLoading; + } + + if (!isLoading && products.length === 0) return null; return ( <Skeleton - isLoaded={!productSimilar.isLoading} + isLoaded={!isLoading} rounded='lg' className='h-[350px]' > @@ -26,4 +61,4 @@ const SimilarBottom = ({ product }: Props) => { ); } -export default SimilarBottom
\ No newline at end of file +export default SimilarBottom;
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/SimilarSide.tsx b/src-migrate/modules/product-detail/components/SimilarSide.tsx index d70a314d..51d9eff7 100644 --- a/src-migrate/modules/product-detail/components/SimilarSide.tsx +++ b/src-migrate/modules/product-detail/components/SimilarSide.tsx @@ -1,33 +1,75 @@ import { Skeleton } from '@chakra-ui/react' +import { useQuery } from 'react-query' import ProductCard from '~/modules/product-card' -import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar' -import { IProductDetail } from '~/types/product' +// Import service +import { getProductSimilar, getProductsByIds } from '~/services/product' +// TAMBAHKAN 'IProduct' DISINI +import { IProduct, IProductDetail } from '~/types/product' type Props = { product: IProductDetail + relatedIds?: number[] } -const SimilarSide = ({ product }: Props) => { - const productSimilar = useProductSimilar({ - name: product.name, - except: { productId: product.id, manufactureId: product.manufacture.id }, - }) +const SimilarSide = ({ product, relatedIds = [] }: Props) => { + + const hasRelated = relatedIds.length > 0; - const products = productSimilar.data?.products || [] + // 1. Fetch Related by ID + const relatedQuery = useQuery({ + queryKey: ['product-related', relatedIds], + queryFn: () => getProductsByIds({ ids: relatedIds }), + enabled: hasRelated, + staleTime: 1000 * 60 * 5, + }); + + // 2. Fetch Similar Biasa + const similarQuery = useQuery({ + queryKey: ['product-similar-side', product.name], + queryFn: () => getProductSimilar({ + name: product.name, + except: { + productId: product.id, + manufactureId: product.manufacture?.id + } + }), + enabled: !hasRelated, + staleTime: 1000 * 60 * 5, + }); + + // ============================================================ + // PERBAIKAN: Definisikan tipe array secara eksplisit (IProduct[]) + // ============================================================ + let products: IProduct[] = []; + let isLoading = false; + + if (hasRelated) { + // Cast ke any dulu jika tipe return service belum sempurna terdeteksi, lalu ambil products + // Atau jika getProductsByIds me-return { products: IProduct[] }, ambil .products + // Sesuai kode service terakhir, getProductsByIds me-return GetProductSimilarRes yg punya .products + products = (relatedQuery.data as any)?.products || []; + isLoading = relatedQuery.isLoading; + } else { + products = similarQuery.data?.products || []; + isLoading = similarQuery.isLoading; + } + + if (!isLoading && products.length === 0) return null; return ( <Skeleton - isLoaded={!productSimilar.isLoading} - className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg" + isLoaded={!isLoading} + className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg p-2" rounded='lg' > - {products.map((product) => ( - <ProductCard - key={product.id} - product={product} - layout='horizontal' - /> + {products.map((item) => ( + <div key={item.id} className="pt-2 first:pt-0"> + <ProductCard + product={item} + layout='horizontal' + /> + </div> ))} </Skeleton> ) diff --git a/src-migrate/modules/promo/components/Hero.tsx b/src-migrate/modules/promo/components/Hero.tsx index 7d0aad11..1004932b 100644 --- a/src-migrate/modules/promo/components/Hero.tsx +++ b/src-migrate/modules/promo/components/Hero.tsx @@ -26,11 +26,12 @@ const swiperBanner: SwiperProps = { disableOnInteraction: false, }, loop: true, - className: 'h-[400px] w-full', + className: 'h-auto w-full', slidesPerView: 1, - spaceBetween: 10, - pagination: true, + spaceBetween: 0, + pagination: { clickable: true }, }; + const swiperBannerMob = { autoplay: { delay: 6000, @@ -38,7 +39,7 @@ const swiperBannerMob = { }, modules: [Pagination, Autoplay], loop: true, - className: 'border border-gray_r-6 min-h-full', + className: 'mobile-swiper w-full', slidesPerView: 1, }; @@ -53,39 +54,54 @@ const Hero = () => { [heroBanner.data] ); - const swiperBannerMobile = { + const isSingleSlide = banners.length <= 1; + + const swiperSettingsDesktop = useMemo(() => ({ + ...swiperBanner, + loop: !isSingleSlide, + allowTouchMove: !isSingleSlide, + autoplay: isSingleSlide ? false : swiperBanner.autoplay, + pagination: isSingleSlide ? false : swiperBanner.pagination, + }), [isSingleSlide]); + + + const swiperSettingsMobile = useMemo(() => ({ ...swiperBannerMob, - pagination: { dynamicBullets: false, clickable: true }, - }; + loop: !isSingleSlide, + allowTouchMove: !isSingleSlide, + autoplay: isSingleSlide ? false : swiperBannerMob.autoplay, + pagination: isSingleSlide ? false : { dynamicBullets: false, clickable: true }, + }), [isSingleSlide]); return ( <> + <style jsx global>{` + @media (max-width: 768px) { + .mobile-swiper .swiper-pagination-bullet { + width: 6px !important; + height: 6px !important; + margin: 0 3px !important; + } + .mobile-swiper .swiper-pagination-bullet-active { + width: 6px !important; + height: 6px !important; + background: red !important; + } + } + `}</style> <DesktopView> - <div className={style['wrapper']}> - <Swiper {...swiperBanner}> + <div className={style['desktop-container']}> + <Swiper {...swiperSettingsDesktop} spaceBetween={10}> {banners?.map((banner, index) => ( <SwiperSlide key={index} className='flex flex-row'> - <div className={style['desc-section']}> - <div className={style['title']}> - {banner?.headlineBanner - ? banner?.headlineBanner - : 'Pasti Hemat & Untung Selama Belanja di Indoteknik.com!'} - </div> - <div className='h-4' /> - <div className={style['subtitle']}> - {banner?.descriptionBanner - ? banner?.descriptionBanner - : 'Cari paket yang kami sediakan dengan penawaran harga & Nikmati kemudahan dalam setiap transaksi dengan fitur lengkap Pembayaran hingga barang sampai!'} - </div> - </div> - <div className={style['banner-section']}> + <div className={style['desktop-image-wrapper']}> <Image src={banner.image} alt={banner.name} - width={666} - height={450} - quality={85} - className='w-full h-full object-fit object-center rounded-2xl' + fill + priority + quality={100} + className={style['banner-image']} /> </div> </SwiperSlide> @@ -94,23 +110,26 @@ const Hero = () => { </div> </DesktopView> <MobileView> - <Swiper {...swiperBannerMobile}> - {banners?.map((banner, index) => ( - <SwiperSlide key={index}> - <Image - width={439} - height={150} - quality={85} - src={banner?.image} - alt={banner?.name} - className='w-full h-full object-cover object-center rounded-2xl' - /> - </SwiperSlide> - ))} - </Swiper> + <div className={style['mobile-container']}> + <Swiper {...swiperSettingsMobile}> + {banners?.map((banner, index) => ( + <SwiperSlide key={index}> + <div className={style['mobile-image-wrapper']}> + <Image + src={banner?.image} + alt={banner?.name} + fill + quality={85} + className={style['banner-image']} + /> + </div> + </SwiperSlide> + ))} + </Swiper> + </div> </MobileView> </> ); }; -export default Hero; +export default Hero;
\ No newline at end of file diff --git a/src-migrate/modules/promo/styles/hero.module.css b/src-migrate/modules/promo/styles/hero.module.css index a5ba6ecc..ad423d62 100644 --- a/src-migrate/modules/promo/styles/hero.module.css +++ b/src-migrate/modules/promo/styles/hero.module.css @@ -25,3 +25,23 @@ md:justify-center md:pr-10; } + +.desktop-container { + @apply w-full px-4 md:px-0 -mt-[15px]; +} + +.desktop-image-wrapper { + @apply w-full h-[375px] relative rounded-2xl overflow-hidden; +} + +.mobile-container { + @apply w-full px-0 -mt-[20px]; +} + +.mobile-image-wrapper { + @apply w-full aspect-[3.2] relative rounded-2xl overflow-hidden; +} + +.banner-image { + @apply object-cover object-center; +} diff --git a/src-migrate/services/product.ts b/src-migrate/services/product.ts index 77b645f0..fa9dae54 100644 --- a/src-migrate/services/product.ts +++ b/src-migrate/services/product.ts @@ -64,3 +64,55 @@ export const getProductCategoryBreadcrumb = async ( ): Promise<ICategoryBreadcrumb[]> => { return await odooApi('GET', `/api/v1/product/${id}/category-breadcrumb`); }; + +// ================================================================= +// TAMBAHAN BARU: SERVICE FETCH BY LIST ID (UNTUK UPSELL/RELATED) +// ================================================================= + +export interface GetProductsByIdsProps { + ids: number[]; +} + +export const getProductsByIds = async ({ + ids, +}: GetProductsByIdsProps): Promise<GetProductSimilarRes> => { + if (!ids || ids.length === 0) { + return { products: [], num_found: 0, num_found_exact: true, start: 0 }; + } + + const idQuery = ids.join(' OR '); + + const query = [ + `q=*`, + `fq=(id:(${idQuery}) OR product_id_i:(${idQuery}))`, + 'rows=20', + `source=upsell`, + ]; + + const url = `${SELF_HOST}/api/shop/search?${query.join('&')}`; + + // Request + const res = await fetch(url).then((res) => res.json()); + + // LOG 2: Hasil Pencarian SOLR + console.group("🔍 2. [Solr Search Result]"); + console.log("Request URL:", url); + console.log("Requested IDs:", ids); + + const foundDocs = res.response?.docs || []; + const foundIds = foundDocs.map((doc: any) => doc.id || doc.product_id_i); + + console.log("Found Products Count:", res.response?.numFound); + console.log("Found IDs:", foundIds); + + // Cek ID mana yang hilang + const missingIds = ids.filter((reqId) => !foundIds.includes(String(reqId)) && !foundIds.includes(Number(reqId))); + if (missingIds.length > 0) { + console.warn("⚠️ MISSING / NOT FOUND IDs:", missingIds); + } else { + console.log("✅ All IDs Found!"); + } + console.groupEnd(); + + return snakeCase(res.response); +};
\ No newline at end of file diff --git a/src-migrate/types/productVariant.ts b/src-migrate/types/productVariant.ts index 5144e7c1..31cedf8c 100644 --- a/src-migrate/types/productVariant.ts +++ b/src-migrate/types/productVariant.ts @@ -4,6 +4,9 @@ export interface IProductVariantDetail { code: string; name: string; weight: number; + attribute_set_id: number; + attribute_set_name: string; + search_keywords: string; is_in_bu: boolean; is_flashsale: { remaining_time: number; diff --git a/src/core/components/elements/Navbar/NavbarDesktop.jsx b/src/core/components/elements/Navbar/NavbarDesktop.jsx index db4fcbb8..2f3f8682 100644 --- a/src/core/components/elements/Navbar/NavbarDesktop.jsx +++ b/src/core/components/elements/Navbar/NavbarDesktop.jsx @@ -13,6 +13,7 @@ import { MenuItem, MenuList, useDisclosure, + Badge, } from '@chakra-ui/react'; import { ChevronDownIcon, HeartIcon } from '@heroicons/react/24/outline'; import dynamic from 'next/dynamic'; @@ -271,11 +272,11 @@ const NavbarDesktop = () => { aria-label='Promo' className={`${ router.asPath === '/shop/promo' && 'bg-gray_r-3' - } flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group relative`} // Added relative position + } flex-[1.5] flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group relative`} target='_blank' rel='noreferrer' > - {showPopup && ( + {/* {showPopup && ( <div className='w-full h-full relative justify-end items-start'> <Image src='/images/penawaran-terbatas.jpg' @@ -288,9 +289,12 @@ const NavbarDesktop = () => { loading='eager' /> </div> - )} - <span className='absolute inset-0 flex justify-center items-center group-hover:scale-105 group-hover:text-red-500 transition-transform duration-200 z-10'> + )} */} + <span className='absolute inset-0 flex justify-center items-center gap-2 group-hover:scale-105 group-hover:text-red-500 transition-transform duration-200 z-10'> Semua Promo + <Badge colorScheme="red" variant="solid" borderRadius="full" px={2} textTransform="none"> + Baru + </Badge> </span> </Link> {/* {showPopup && router.pathname === '/' && ( @@ -306,7 +310,7 @@ const NavbarDesktop = () => { aria-label='Brand' className={`${ router.asPath === '/shop/brands' && 'bg-gray_r-3' - } p-4 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} + } px-2 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} target='_blank' rel='noreferrer' > @@ -320,7 +324,7 @@ const NavbarDesktop = () => { className={`${ router.asPath.includes('/shop/search?orderBy=stock') && 'bg-gray_r-3' - } p-4 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} + } px-2 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} target='_blank' rel='noreferrer' > @@ -331,7 +335,7 @@ const NavbarDesktop = () => { <Link href='https://blog.indoteknik.com/' aria-label='Blog Indoteknik' - className='p-4 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group' + className='px-2 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group' target='_blank' rel='noreferrer noopener' > @@ -506,4 +510,4 @@ const SocialMedias = () => ( </div> ); -export default NavbarDesktop; +export default NavbarDesktop;
\ No newline at end of file diff --git a/src/lib/category/components/Breadcrumb.jsx b/src/lib/category/components/Breadcrumb.jsx index acd2cbff..fa2846e4 100644 --- a/src/lib/category/components/Breadcrumb.jsx +++ b/src/lib/category/components/Breadcrumb.jsx @@ -11,11 +11,14 @@ import React from 'react'; import { useQuery } from 'react-query'; import useDevice from '@/core/hooks/useDevice'; -const Breadcrumb = ({ categoryId, currentLabel }) => { +const Breadcrumb = ({ categoryId, shortDesc }) => { const breadcrumbs = useQuery( ['category-breadcrumbs', categoryId], async () => - await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) + await odooApi( + 'GET', + `/api/v1/category/${categoryId}/category-breadcrumb`, + ), ); const { isDesktop, isMobile } = useDevice(); @@ -23,7 +26,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { /* ========================= DESKTOP - ========================== */ + ========================= */ if (isDesktop) { return ( <div className='container mx-auto py-4 md:py-6'> @@ -67,7 +70,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { href={createSlug( '/shop/category/', category.name, - category.id + category.id, )} className='!text-danger-500' > @@ -92,13 +95,28 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { )} </ChakraBreadcrumb> </Skeleton> + {shortDesc && ( + <div + className=' + w-full mt-2 + text-sm text-neutral-600 + leading-7 + text-justify + break-words + [hyphens:auto] + max-w-none + ' + > + {shortDesc} + </div> + )} </div> ); } /* ========================= MOBILE - ========================== */ + ========================= */ if (isMobile) { const n = items.length; const lastCat = n >= 1 ? items[n - 1] : null; @@ -148,7 +166,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { </BreadcrumbLink> </BreadcrumbItem> - {/* Ellipsis */} + {/* Jika ada kategori sebelum secondLast, tampilkan '..' (link ke beforeSecond) */} {beforeSecond && ( <BreadcrumbItem> <BreadcrumbLink @@ -156,10 +174,9 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { href={createSlug( '/shop/category/', beforeSecond.name, - beforeSecond.id + beforeSecond.id, )} title={hiddenText} - aria-label={`Kembali ke ${beforeSecond.name}`} className='!text-danger-500' > .. @@ -167,7 +184,6 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { </BreadcrumbItem> )} - {/* Second last category */} {secondLast && ( <BreadcrumbItem> <BreadcrumbLink @@ -175,7 +191,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { href={createSlug( '/shop/category/', secondLast.name, - secondLast.id + secondLast.id, )} className='!text-danger-500' > @@ -184,13 +200,13 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { </BreadcrumbItem> )} - {/* Current */} - {finalLabel && ( + {/* lastCat (current) dengan truncate & lebar dibatasi */} + {lastCat && ( <BreadcrumbItem isCurrentPage> <span className='inline-block truncate align-bottom' style={{ maxWidth: '60vw' }} - title={finalLabel} + title={lastCat.name} > {finalLabel} </span> @@ -198,6 +214,22 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { )} </ChakraBreadcrumb> </Skeleton> + + {shortDesc && ( + <div + className=' + w-full mt-2 + text-sm text-neutral-600 + leading-7 + text-justify + break-words + [hyphens:auto] + max-w-none + ' + > + {shortDesc} + </div> + )} </div> ); } diff --git a/src/lib/category/components/styles/breadcrumb.module.css b/src/lib/category/components/styles/breadcrumb.module.css new file mode 100644 index 00000000..dee4e1b4 --- /dev/null +++ b/src/lib/category/components/styles/breadcrumb.module.css @@ -0,0 +1,3 @@ +.category-short-desc { + flex: 0 0 100%; +} diff --git a/src/lib/checkout/api/getRatesCourier.js b/src/lib/checkout/api/getRatesCourier.js index 30cfe6e1..0108a3b8 100644 --- a/src/lib/checkout/api/getRatesCourier.js +++ b/src/lib/checkout/api/getRatesCourier.js @@ -5,8 +5,7 @@ const GetRatesCourierBiteship = async ({ destination, items }) => { const couriers = process.env.NEXT_PUBLIC_BITESHIP_CODE_COURIERS; let body = { ...destination, - couriers: - 'gojek, grab, deliveree, lalamove, jne, tiki, ninja, lion, rara, sicepat, jnt, pos, idexpress, rpx, wahana, jdl, pos, anteraja, sap, paxel, borzo', + couriers: couriers, items: items, }; diff --git a/src/lib/checkout/components/SectionExpedition.jsx b/src/lib/checkout/components/SectionExpedition.jsx index 66182589..16a2c664 100644 --- a/src/lib/checkout/components/SectionExpedition.jsx +++ b/src/lib/checkout/components/SectionExpedition.jsx @@ -250,7 +250,7 @@ export default function SectionExpedition({ products }) { let body = { ...destination, couriers: - 'gojek,grab,deliveree,lalamove,jne,tiki,ninja,lion,rara,sicepat,jnt,pos,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', + 'gojek,grab,deliveree,lalamove,jne,ninja,lion,rara,sicepat,jnt,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', items: items, }; try { diff --git a/src/lib/checkout/components/SectionQuotationExpedition.jsx b/src/lib/checkout/components/SectionQuotationExpedition.jsx index 817cd21b..718e096c 100644 --- a/src/lib/checkout/components/SectionQuotationExpedition.jsx +++ b/src/lib/checkout/components/SectionQuotationExpedition.jsx @@ -155,8 +155,7 @@ export default function SectionExpeditionQuotation({ products }) { const fetchExpedition = async () => { const body = { ...destination, - couriers: - 'gojek,grab,deliveree,lalamove,jne,tiki,ninja,lion,rara,sicepat,jnt,pos,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', + couriers: 'gojek,grab,deliveree,lalamove,jne,ninja,lion,rara,sicepat,jnt,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', items, }; const response = await axios.get(`/api/biteship-service`, { diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx index 850d00cc..c73c7036 100644 --- a/src/lib/product/components/ProductSearch.jsx +++ b/src/lib/product/components/ProductSearch.jsx @@ -6,7 +6,10 @@ import { HStack, Image, Tag, TagCloseButton, TagLabel } from '@chakra-ui/react'; import axios from 'axios'; import _ from 'lodash'; import { toQuery } from 'lodash-contrib'; -import { FunnelIcon, AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline'; +import { + FunnelIcon, + AdjustmentsHorizontalIcon, +} from '@heroicons/react/24/outline'; import odooApi from '@/core/api/odooApi'; import searchSpellApi from '@/core/api/searchSpellApi'; import Link from '@/core/components/elements/Link/Link'; @@ -57,9 +60,15 @@ const ProductSearch = ({ if (!router.isReady) return; const onBrandsPage = router.pathname.includes('brands'); - const hasOrder = typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; - - if (onBrandsPage && !hasOrder && !appliedDefaultBrandOrder.current) { + const onSearchPage = prefixUrl === '/shop/search'; + const hasOrder = + typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; + + if ( + (onBrandsPage || onSearchPage) && + !hasOrder && + !appliedDefaultBrandOrder.current + ) { let params = { ...router.query, orderBy: 'popular', @@ -83,7 +92,7 @@ const ProductSearch = ({ const loadProduct = async () => { const getCategoriesId = await odooApi( 'GET', - `/api/v1/category/numFound?parent_id=${categoryId}` + `/api/v1/category/numFound?parent_id=${categoryId}`, ); if (getCategoriesId) { setDataCategoriesProduct(getCategoriesId); @@ -94,7 +103,7 @@ const ProductSearch = ({ const loadProduct = async () => { const lobData = await odooApi( 'GET', - `/api/v1/lob_homepage/${categoryId}/category_id` + `/api/v1/lob_homepage/${categoryId}/category_id`, ); if (lobData) { @@ -175,7 +184,11 @@ const ProductSearch = ({ }, [dataCategoriesProduct, dataLob]); useEffect(() => { - if (prefixUrl.includes('category') || prefixUrl.includes('lob') || router.asPath.includes('penawaran')) { + if ( + prefixUrl.includes('category') || + prefixUrl.includes('lob') || + router.asPath.includes('penawaran') + ) { setQueryFinal({ ...finalQuery, q, limit, orderBy }); } else { setQueryFinal({ ...query, q, limit, orderBy }); @@ -198,10 +211,10 @@ const ProductSearch = ({ ? router.query.brand ? router.query.brand.split(',') : [] - : [] + : [], ); const [categoryValues, setCategory] = useState( - router.query?.category?.split(',') || router.query?.category?.split(',') + router.query?.category?.split(',') || router.query?.category?.split(','), ); const [priceFrom, setPriceFrom] = useState(router.query?.priceFrom || null); @@ -217,11 +230,11 @@ const ProductSearch = ({ if (productFound == 0 && query.q && !spellings) { searchSpellApi({ query: query.q }).then((response) => { const oddIndexSuggestions = response.data.spellcheck.suggestions.filter( - (_, index) => index % 2 === 1 + (_, index) => index % 2 === 1, ); const oddIndexCollations = response.data.spellcheck.collations.filter( - (_, index) => index % 2 === 1 + (_, index) => index % 2 === 1, ); const dataSpellings = oddIndexSuggestions.reduce((acc, curr) => { @@ -246,7 +259,7 @@ const ProductSearch = ({ useEffect(() => { const checkIfBrand = async () => { const brand = await axios( - `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/brands?params=search&q=${search}` + `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/brands?params=search&q=${search}`, ); if (brand.data.length > 0) { @@ -265,7 +278,7 @@ const ProductSearch = ({ const loadCategories = async () => { const getCategories = await odooApi( 'GET', - `/api/v1/category/child?parent_id=${categoryId}` + `/api/v1/category/child?parent_id=${categoryId}`, ); if (getCategories) { setDataCategories(getCategories); @@ -335,15 +348,15 @@ const ProductSearch = ({ if (router.pathname.includes('search')) { const getBannerHeader = await odooApi( 'GET', - '/api/v1/banner?type=promotion-header' + '/api/v1/banner?type=promotion-header', ); const getBannerFooter = await odooApi( 'GET', - '/api/v1/banner?type=promotion-footer' + '/api/v1/banner?type=promotion-footer', ); var randomIndex = Math.floor(Math.random() * getBannerHeader.length); var randomIndexFooter = Math.floor( - Math.random() * getBannerFooter.length + Math.random() * getBannerFooter.length, ); setBannerPromotionHeader(getBannerHeader[randomIndex]); setBannerPromotionFooter(getBannerFooter[randomIndexFooter]); @@ -430,7 +443,9 @@ const ProductSearch = ({ <div className='p-4 pt-0'> {isNotReadyStockPage && isBrand && isBrand.logo && ( <div className='mb-3'> - <h1 className='mb-2 font-semibold text-h-sm'>Brand Pencarian {q}</h1> + <h1 className='mb-2 font-semibold text-h-sm'> + Brand Pencarian {q} + </h1> <Link href={createSlug('/shop/brands/', isBrand.name, isBrand.id)} className='inline' @@ -462,7 +477,8 @@ const ProductSearch = ({ {pageCount > 1 ? ( <> {productStart + 1}- - {parseInt(productStart) + parseInt(productRows) > productFound + {parseInt(productStart) + parseInt(productRows) > + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -474,7 +490,8 @@ const ProductSearch = ({ produk{' '} {query.q && ( <> - untuk pencarian <span className='font-semibold'>{query.q}</span> + untuk pencarian{' '} + <span className='font-semibold'>{query.q}</span> </> )} </> @@ -512,7 +529,9 @@ const ProductSearch = ({ </div> )} {!!dataLob?.length && <LobSectionCategory categories={dataLob} />} - {!!dataCategories?.length && <CategorySection categories={dataCategories} />} + {!!dataCategories?.length && ( + <CategorySection categories={dataCategories} /> + )} <div className='grid grid-cols-2 gap-3'> {products && products.map((product) => ( @@ -621,7 +640,7 @@ const ProductSearch = ({ <> {productStart + 1}- {parseInt(productStart) + parseInt(productRows) > - productFound + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -629,12 +648,11 @@ const ProductSearch = ({ ) : ( '' )} - {productFound} + <strong>{productFound}</strong> produk{' '} {query.q && ( <> - untuk pencarian{' '} - <span className='font-semibold'>{query.q}</span> + untuk pencarian <strong>{query.q}</strong> </> )} </> @@ -697,8 +715,8 @@ const ProductSearch = ({ href={ query?.q ? whatsappUrl('productSearch', { - name: query.q, - }) + name: query.q, + }) : whatsappUrl() } className='text-danger-500' @@ -783,9 +801,9 @@ const FilterChoicesComponent = ({ </Tag> )} {brandValues?.length > 0 || - categoryValues?.length > 0 || - priceFrom || - priceTo ? ( + categoryValues?.length > 0 || + priceFrom || + priceTo ? ( <span> <button className='btn-transparent py-2 px-5 h-[40px] text-red-700' diff --git a/src/pages/api/magento-product.ts b/src/pages/api/magento-product.ts new file mode 100644 index 00000000..28738878 --- /dev/null +++ b/src/pages/api/magento-product.ts @@ -0,0 +1,168 @@ +// pages/api/magento-product.ts +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + // Kita terima 'skus' (banyak) dan 'main_sku' (utama/pertama) + const { skus, main_sku } = req.query; + + if (!skus) { + return res.status(400).json({ error: 'SKUs are required' }); + } + + const token = process.env.MAGENTO_API_KEY || ''; + const baseUrl = process.env.MAGENTO_API_HOST || ''; + + try { + const skuList = String(skus).split(','); // Contoh: ['221', '222', '223'] + const mainSku = String(main_sku || skuList[0]).trim(); // Fallback ke yang pertama + + // ===================================================================== + // 1. FETCH SEMUA VARIAN SEKALIGUS (Optimasi 'IN' Operator) + // ===================================================================== + const searchParams = new URLSearchParams({ + 'searchCriteria[filter_groups][0][filters][0][field]': 'sku', + 'searchCriteria[filter_groups][0][filters][0][value]': skuList.join(','), + 'searchCriteria[filter_groups][0][filters][0][condition_type]': 'in' + }); + + const productUrl = `${baseUrl}/products?${searchParams.toString()}`; + + const productResponse = await fetch(productUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!productResponse.ok) { + return res.status(200).json({ specsMatrix: [], upsell_ids: [], related_ids: [] }); + } + + const productData = await productResponse.json(); + const items = productData.items || []; + + if (items.length === 0) { + return res.status(200).json({ specsMatrix: [], upsell_ids: [], related_ids: [] }); + } + + const cleanAttributeValue = (val: any) => { + if (val === null || val === undefined) return ''; + let str = String(val).trim(); + if (str.length >= 2 && str.startsWith('"') && str.endsWith('"')) { + str = str.slice(1, -1).trim(); + } + return str; + }; + + // ===================================================================== + // 2. BUILD SPECS MATRIX + // ===================================================================== + + // Kumpulkan semua kode atribut unik + const allAttributeCodes = new Set<string>(); + items.forEach((p: any) => { + if (p.custom_attributes) { + p.custom_attributes.forEach((attr: any) => { + if (attr.attribute_code.startsWith('z')) { + allAttributeCodes.add(attr.attribute_code); + } + }); + } + }); + + // Fetch Label untuk atribut-atribut tersebut (Sekali jalan) + const labelsMap: Record<string, string> = {}; + await Promise.all(Array.from(allAttributeCodes).map(async (code) => { + try { + const attrUrl = `${baseUrl}/products/attributes/${code}`; + const res = await fetch(attrUrl, { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) { + const json = await res.json(); + labelsMap[code] = json.default_frontend_label || code; + } + } catch (e) {} + + // Fallback label jika gagal + if (!labelsMap[code]) { + labelsMap[code] = code.substring(1).replace(/_/g, ' ').trim(); + } + })); + + // Susun Matrix + // Struktur: { code, label, values: { [sku]: value } } + const matrix: any[] = []; + + Array.from(allAttributeCodes).forEach((code) => { + const row: any = { + code: code, + label: labelsMap[code], + values: {} + }; + + let hasData = false; + + items.forEach((p: any) => { + const attr = p.custom_attributes.find((a: any) => a.attribute_code === code); + // Gunakan helper cleanAttributeValue disini + const rawVal = attr ? cleanAttributeValue(attr.value) : ''; + + if (rawVal !== '' && rawVal !== '-') { + hasData = true; + } + row.values[p.sku] = rawVal; + }); + + if (hasData) { + matrix.push(row); + } + }); + + // Deskripsi produk per varian + const descriptions:Record<string, string> = {}; + items.forEach((p: any) => { + const descAttr = p.custom_attributes.find((a: any) => a.attribute_code === 'description' || a.attribute_code === 'short_description'); + descriptions[p.sku] = descAttr ? descAttr.value : ''; + }); + + const warranties: Record<string, string> = {}; + items.forEach((p: any) => { + const warAttr = p.custom_attributes.find((a: any) => a.attribute_code === 'z_warranty'); + warranties[p.sku] = warAttr ? cleanAttributeValue(warAttr.value) : ''; + }); + + // ===================================================================== + // 3. AMBIL LINKS (UPSELL & RELATED) DARI MAIN VARIANT SAJA + // ===================================================================== + const mainProduct = items.find((p: any) => String(p.sku) === mainSku) || items[0]; + + let upsellIds: number[] = []; + let relatedIds: number[] = []; + + if (mainProduct && mainProduct.product_links) { + mainProduct.product_links.forEach((link: any) => { + if (link.link_type === 'upsell') { + upsellIds.push(Number(link.linked_product_sku)); + } else if (link.link_type === 'related') { + relatedIds.push(Number(link.linked_product_sku)); + } + }); + } + + // Response + res.status(200).json({ + specsMatrix: matrix, + upsell_ids: upsellIds, + related_ids: relatedIds, + descriptions: descriptions, + warranties: warranties, + }); + + } catch (error) { + console.error('Proxy Error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}
\ No newline at end of file diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index 5f77b5c6..1f636f28 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -2,6 +2,18 @@ import { productMappingSolr } from '@/utils/solrMapping'; import axios from 'axios'; import camelcaseObjectDeep from 'camelcase-object-deep'; +const escapeSolrQuery = (query) => { + if (query == '*') return query; + query = query.replace(/-/g, ' '); + const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; + const words = query.split(/\s+/); + return words + .map((word) => + specialChars.test(word) ? word.replace(specialChars, '\\$1') : word, + ) + .join(' '); +}; + export default async function handler(req, res) { const { q = '*', @@ -12,71 +24,226 @@ export default async function handler(req, res) { priceTo = 0, orderBy = '', operation = 'AND', - fq = '', + fq = '', // bisa berupa string atau array limit = 30, source = '', + group = 'true', } = req.query; let { stock = '' } = req.query; // ============================================================ - // SITEMAP + // [PERBAIKAN] 1. LOGIC KHUSUS COMPARE (PAKAI URLSearchParams) // ============================================================ - if (source === 'sitemap') { + if (source === 'compare') { try { - const offset = (page - 1) * limit; + let qCompare = q === '*' ? '*:*' : q; + + if (qCompare !== '*:*') { + qCompare = escapeSolrQuery(qCompare); + qCompare = qCompare + .split(/\s+/) + .map((term) => { + if (term && !term.includes('*')) { + return term + '*'; + } + return term; + }) + .join(' '); + } + + // [SOLUSI] Gunakan URLSearchParams untuk menyusun URL dengan aman + const params = new URLSearchParams(); + + params.append('q', qCompare); + params.append('rows', limit); + params.append('wt', 'json'); + params.append('indent', 'true'); + + // Gunakan eDisMax parser (Otak Cerdas) + params.append('defType', 'edismax'); + + // Set Prioritas Pencarian (Boost ^) + // 1. default_code_s^20 : SKU persis (Prioritas Tertinggi) + // 2. search_keywords_t^10 : Field baru (Case insensitive) + // 3. display_name_s^1 : Cadangan + params.append( + 'qf', + 'default_code_s^20 search_keywords_t^10 display_name_s^1', + ); + + const compareWords = qCompare.split(/\s+/).filter((w) => w.length > 0); + let compareMm = '100%'; + if (compareWords.length >= 3) { + compareMm = '75%'; + } + params.append('mm', compareMm); + + if (group === 'false') { + params.append('group', 'false'); + } else { + params.append('group', 'true'); + params.append('group.field', 'template_id_i'); + params.append('group.limit', '1'); + params.append('group.main', 'true'); + } + + // Field List (fl) + params.append( + 'fl', + 'id,display_name_s,default_code_s,image_s,price_tier1_v2_f,attribute_set_id_i,attribute_set_name_s,template_id_i,product_id_i', + ); + + // Filter Query (fq) Dasar + params.append('fq', '-publish_b:false'); + params.append('fq', 'price_tier1_v2_f:[1 TO *]'); + + // Logic Locking (Filter Attribute Set ID dari Frontend) + if (fq) { + if (Array.isArray(fq)) { + fq.forEach((f) => params.append('fq', f)); + } else { + params.append('fq', fq); + } + } + + // Target Core: VARIANTS + // HAPUS parameter manual dari string URL, gunakan params object + const solrUrl = process.env.SOLR_HOST + '/solr/variants/select'; + + // Axios akan otomatis handle encoding % dan & dengan benar + const result = await axios.get(solrUrl, { params: params }); + + // Mapping Result + const mappedProducts = productMappingSolr( + result.data.response.docs, + false, + ); + + const finalResponse = { + ...result.data, + response: { + ...result.data.response, + products: mappedProducts, + }, + }; + + delete finalResponse.response.docs; + const camelCasedData = camelcaseObjectDeep(finalResponse); + + return res.status(200).json(camelCasedData); + } catch (e) { + console.error('[COMPARE SEARCH ERROR]', e.message); + if (e.response && e.response.data) { + // Log detail error dari Solr + console.error( + '[SOLR DETAILS]:', + JSON.stringify(e.response.data, null, 2), + ); + } + return res.status(200).json({ response: { products: [], numFound: 0 } }); + } + } + + // ============================================================ + // LOGIC KHUSUS UPSELL (KODE LAMA ANDA) + // ============================================================ + if (source === 'upsell') { + try { + // Ambil fq dari query (format: product_id_i:(...)) + // Pastikan fq adalah string tunggal + let fqUpsell = Array.isArray(fq) ? fq.join(' OR ') : fq; + fqUpsell = decodeURIComponent(fqUpsell); const parameter = [ 'q=*:*', `rows=${limit}`, - `start=${offset}`, - 'fl=product_id_i,name_s,default_code_s,image_s,category_name', 'wt=json', - 'omitHeader=true', + 'indent=true', + 'defType=edismax', + // Filter Query khusus Upsell + `fq=${encodeURIComponent(fqUpsell)}`, + // Tetap filter yang publish & ada harga agar produk valid + `fq=${encodeURIComponent('-publish_b:false')}`, + `fq=${encodeURIComponent('price_tier1_v2_f:[1 TO *]')}`, ]; - // const parameter = [ - // 'q=*:*', - // `rows=${limit}`, - // `start=${offset}`, + // PENTING: SEARCH DI CORE 'VARIANTS' + const solrUrl = + process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&'); - // // ❌ EXCLUDE PROMOTION - // 'fq=-(name_s:*promotion* OR display_name_s:*promotion* OR variants_name_t:*promotion*)', + const result = await axios(solrUrl); - // // ❌ EXCLUDE DUMMY PRODUCT - // 'fq=-(name_s:*dummy* OR display_name_s:*dummy* OR variants_name_t:*dummy* OR default_code_s:A.*)', + // 1. Mapping dasar + const mappedProducts = productMappingSolr( + result.data.response.docs, + false, + ); - // 'fl=product_id_i,name_s,default_code_s,image_s,category_name', - // 'wt=json', - // 'omitHeader=true', - // ]; + // 2. FIX URL LINK: Override ID Varian dengan Template ID + const rawDocs = result.data.response.docs; + + const fixedProducts = mappedProducts.map((p, index) => { + const raw = rawDocs[index]; + if (raw && raw.template_id_i) { + return { + ...p, + id: raw.template_id_i, // Ganti ID Varian jadi ID Template agar link valid + variantId: raw.product_id_i, + }; + } + return p; + }); + + const finalResponse = { + ...result.data, + response: { + ...result.data.response, + products: fixedProducts, + }, + }; + + delete finalResponse.response.docs; + const camelCasedData = camelcaseObjectDeep(finalResponse); + + return res.status(200).json(camelCasedData); + } catch (e) { + console.error('[UPSELL ERROR]', e.response?.data || e.message); + return res.status(200).json({ response: { products: [], numFound: 0 } }); + } + } + // ============================================================ + // SITEMAP (KODE LAMA ANDA) + // ============================================================ + if (source === 'sitemap') { + try { + const offset = (page - 1) * limit; + const parameter = [ + 'q=*:*', + `rows=${limit}`, + `start=${offset}`, + 'fl=product_id_i,name_s,default_code_s,image_s,category_name', + 'wt=json', + 'omitHeader=true', + ]; const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); - - // console.log('[SITEMAP SOLR QUERY]', solrUrl); - const result = await axios(solrUrl, { timeout: 25000 }); - - // mapping seperti biasa result.data.response.products = productMappingSolr( result.data.response.docs, - false + false, ); - delete result.data.response.docs; - result.data = camelcaseObjectDeep(result.data); - return res.status(200).json(result.data); } catch (e) { - console.error('[SITEMAP ERROR]', e); return res.status(500).json({ error: 'Sitemap query failed' }); } } // ============================================================ - // SEARCH NORMAL + // SEARCH NORMAL (KODE LAMA ANDA) // ============================================================ let paramOrderBy = ''; @@ -114,7 +281,6 @@ export default async function handler(req, res) { .split(' ') .map((term) => (term.length < 2 ? term : `${term}*`)) .join(' ')})`; - const mm = checkQ.length > 2 ? checkQ.length > 5 @@ -128,23 +294,19 @@ export default async function handler(req, res) { 'price_tier1_v2_f:[1 TO *]', ]; - if (orderBy === 'stock') { - filterQueries.push('stock_total_f:[1 TO *]'); - } + if (orderBy === 'stock') filterQueries.push('stock_total_f:[1 TO *]'); - if (fq && source != 'similar' && typeof fq != 'string') { - fq.push(...filterQueries); + // Handle 'fq' parameter from request + let finalFq = [...filterQueries]; + if (fq) { + if (Array.isArray(fq)) finalFq.push(...fq); + else finalFq.push(fq); } - const fq_ = filterQueries.join(' AND '); - let keywords = newQ; if (source === 'similar' || checkQ.length < 3) { - if (checkQ.length < 2 || checkQ[1].length < 2) { - keywords = newQ; - } else { - keywords = newQ + '*'; - } + if (checkQ.length < 2 || checkQ[1].length < 2) keywords = newQ; + else keywords = newQ + '*'; } else { keywords = formattedQuery; } @@ -164,15 +326,17 @@ export default async function handler(req, res) { `start=${parseInt(offset)}`, `rows=${limit}`, `sort=${paramOrderBy}`, - `fq=${encodeURIComponent(fq_)}`, `mm=${encodeURIComponent(mm)}`, ]; + // Masukkan semua Filter Query (fq) + finalFq.forEach((f) => { + parameter.push(`fq=${encodeURIComponent(f)}`); + }); + if (priceFrom > 0 || priceTo > 0) { parameter.push( - `fq=price_tier1_v2_f:[${priceFrom == '' ? '*' : priceFrom} TO ${ - priceTo == '' ? '*' : priceTo - }]` + `fq=price_tier1_v2_f:[${priceFrom || '*'} TO ${priceTo || '*'}]`, ); } @@ -185,10 +349,7 @@ export default async function handler(req, res) { if (brand) { const brandExpr = brand .split(',') - .map( - (manufacturer) => - `manufacture_name:"${encodeURIComponent(manufacturer)}"` - ) + .map((m) => `manufacture_name:"${encodeURIComponent(m)}"`) .join(' OR '); parameter.push(`fq={!tag=brand}(${brandExpr})`); } @@ -196,7 +357,7 @@ export default async function handler(req, res) { if (category) { const catExpr = category .split(',') - .map((cat) => `category_name:"${encodeURIComponent(cat)}"`) + .map((c) => `category_name:"${encodeURIComponent(c)}"`) .join(' OR '); parameter.push(`fq={!tag=cat}(${catExpr})`); } @@ -207,7 +368,7 @@ export default async function handler(req, res) { if (Array.isArray(fq)) parameter = parameter.concat( - fq.map((val) => `fq=${encodeURIComponent(val)}`) + fq.map((val) => `fq=${encodeURIComponent(val)}`), ); // Searchkey @@ -235,7 +396,7 @@ export default async function handler(req, res) { try { result.data.response.products = productMappingSolr( result.data.response.docs, - auth?.pricelist || false + auth?.pricelist || false, ); delete result.data.response.docs; @@ -247,21 +408,21 @@ export default async function handler(req, res) { } } + // SEARCH NORMAL: DEFAULT KE CORE 'PRODUCT' const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); - const result = await axios(solrUrl); - try { + const result = await axios(solrUrl); result.data.response.products = productMappingSolr( result.data.response.docs, - auth?.pricelist || false + auth?.pricelist || false, ); result.data.responseHeader.params.start = parseInt( - result.data.responseHeader.params.start + result.data.responseHeader.params.start, ); result.data.responseHeader.params.rows = parseInt( - result.data.responseHeader.params.rows + result.data.responseHeader.params.rows, ); delete result.data.response.docs; result.data = camelcaseObjectDeep(result.data); @@ -270,20 +431,3 @@ export default async function handler(req, res) { res.status(400).json({ error: error.message }); } } - -const escapeSolrQuery = (query) => { - if (query == '*') return query; - - query = query.replace(/-/g, ' '); - - const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; - const words = query.split(/\s+/); - const escapedWords = words.map((word) => { - if (specialChars.test(word)) { - return word.replace(specialChars, '\\$1'); - } - return word; - }); - - return escapedWords.join(' '); -}; diff --git a/src/pages/shop/category/[slug].jsx b/src/pages/shop/category/[slug].jsx index 11840d47..e515e3f4 100644 --- a/src/pages/shop/category/[slug].jsx +++ b/src/pages/shop/category/[slug].jsx @@ -16,13 +16,14 @@ const ProductSearch = dynamic(() => ); const CategorySection = dynamic(() => import('@/lib/product/components/CategorySection') -) +); export default function CategoryDetail() { const router = useRouter(); const { slug = '', page = 1 } = router.query; - const [dataCategories, setDataCategories] = useState([]) + const [dataCategories, setDataCategories] = useState([]); + const [shortDesc, setShortDesc] = useState(''); const categoryName = getNameFromSlug(slug); const categoryId = getIdFromSlug(slug); const q = router?.query.q || null; @@ -33,6 +34,22 @@ export default function CategoryDetail() { if (q) { query.q = q; } + useEffect(() => { + if (!router.isReady) return; + if (!categoryId) return; + + const loadShortDesc = async () => { + const res = await odooApi( + 'GET', + `/api/v1/category/${categoryId}/short-desc` + ); + + const desc = res?.shortDesc || ''; + setShortDesc(desc); + }; + + loadShortDesc(); + }, [router.isReady, categoryId]); return ( <BasicLayout> @@ -47,11 +64,14 @@ export default function CategoryDetail() { ]} /> - <Breadcrumb categoryId={categoryId} /> - + <Breadcrumb categoryId={categoryId} shortDesc={shortDesc} /> {!_.isEmpty(router.query) && ( - <ProductSearch query={query} categories ={categoryId} prefixUrl={`/shop/category/${slug}`} /> + <ProductSearch + query={query} + categories={categoryId} + prefixUrl={`/shop/category/${slug}`} + /> )} </BasicLayout> ); diff --git a/src/utils/solrMapping.js b/src/utils/solrMapping.js index 33f0cbaf..8c0abcf1 100644 --- a/src/utils/solrMapping.js +++ b/src/utils/solrMapping.js @@ -127,6 +127,9 @@ export const variantsMappingSolr = (parent, products, pricelist) => { variantTotal: product.variant_total_i || 0, stockTotal: product.stock_total_f || 0, weight: product.weight_f || 0, + attribute_set_id: product.attribute_set_id_i || 0, + attribute_set_name: product.attribute_set_name_s || '', + search_keywords: product.search_keywords_t || '', manufacture: {}, parent: {}, qtySold: product?.qty_sold_f || 0, |
