diff options
| author | FIN-IT_AndriFP <andrifebriyadiputra@gmail.com> | 2026-01-14 17:47:45 +0700 |
|---|---|---|
| committer | FIN-IT_AndriFP <andrifebriyadiputra@gmail.com> | 2026-01-14 17:47:45 +0700 |
| commit | d885bbb998c31c809b0ff77faa4695c1335a3717 (patch) | |
| tree | 76cd15720147203f679337b0c54c49cb58fcf394 | |
| parent | 561c3c992bdbe743bbf2a4afe16d0019b4e0269c (diff) | |
(andri) dropdown menggunakan autocomplete & fix
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductComparisonModal.tsx | 201 |
1 files changed, 168 insertions, 33 deletions
diff --git a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx index f2d7ca57..00eaebe5 100644 --- a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx +++ b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx @@ -16,6 +16,7 @@ import { Input, InputGroup, InputLeftElement, + InputRightElement, VStack, HStack, IconButton, @@ -25,11 +26,17 @@ import { List, ListItem, useToast, - Select, useOutsideClick } from '@chakra-ui/react'; -import { Search, Trash2 } from 'lucide-react'; +import { + AutoComplete, + AutoCompleteInput, + AutoCompleteItem, + AutoCompleteList, +} from '@choc-ui/chakra-autocomplete'; + +import { Search, Trash2, ChevronDown } from 'lucide-react'; // --- HELPER FORMATTING --- const formatPrice = (price: number) => { @@ -45,6 +52,27 @@ const renderSpecValue = (val: any) => { return String(val).replace(/<[^>]*>?/gm, ''); }; +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; @@ -55,7 +83,6 @@ type Props = { const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant }: Props) => { const toast = useToast(); - // --- STATE --- const [products, setProducts] = useState<(any | null)[]>([null, null, null, null]); const [specsMatrix, setSpecsMatrix] = useState<any[]>([]); const [isLoadingMatrix, setIsLoadingMatrix] = useState(false); @@ -66,7 +93,8 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant const [searchResults, setSearchResults] = useState<any[]>([]); const [isSearching, setIsSearching] = useState(false); - // --- REF & OUTSIDE CLICK --- + const [disableVariantFilter, setDisableVariantFilter] = useState(false); + const searchWrapperRef = useRef<HTMLDivElement>(null); useOutsideClick({ @@ -100,8 +128,10 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant 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 + image: v.image, + attributes: v.attributes || [] })) || []; if (variantOptions.length === 0) { @@ -109,12 +139,21 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant id: targetId, code: displayCode, name: mainProduct.name, + displayName: mainProduct.displayName || mainProduct.name, price: activeItem.price?.price || activeItem.price || 0, - image: activeItem.image || mainProduct.image + 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, @@ -123,7 +162,8 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant name: displayName, price: activeItem.price?.price || activeItem.price || mainProduct.lowest_price?.price || 0, image: activeItem.image || mainProduct.image, - variants: variantOptions + variants: variantOptions, + inputValue: getVariantLabel(tempActiveVar) }; setProducts((prev) => { @@ -181,7 +221,6 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant const attrSetId = selectedVariant?.attribute_set_id || mainProduct?.attribute_set_id; if (!attrSetId) { - console.warn("Search dibatalkan: Produk utama tidak memiliki attribute_set_id"); setSearchResults([]); setIsSearching(false); return; @@ -190,7 +229,6 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant setIsSearching(true); try { const queryParam = searchQuery === '' ? '*' : searchQuery; - const params = new URLSearchParams({ source: 'compare', q: queryParam, @@ -199,7 +237,6 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant }); const res = await fetch(`/api/shop/search?${params.toString()}`); - if (res.ok) { const data = await res.json(); setSearchResults(data.response?.products || []); @@ -219,13 +256,30 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant // =========================================================================== // 4. HANDLERS // =========================================================================== - const handleVariantChange = (slotIndex: number, newId: string) => { + + 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) => String(v.id) === String(newId)); + // Cari varian yang labelnya cocok dengan string input + const selectedVar = currentProduct.variants.find((v: any) => { + return getVariantLabel(v) === selectedValueString; + }); if (selectedVar) { + setDisableVariantFilter(true); const newProducts = [...products]; newProducts[slotIndex] = { ...currentProduct, @@ -234,7 +288,8 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant name: selectedVar.name, realCode: selectedVar.code, price: selectedVar.price, - image: selectedVar.image + image: selectedVar.image, + inputValue: getVariantLabel(selectedVar) }; setProducts(newProducts); } @@ -259,17 +314,11 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant if (!parentId) { try { - const checkParams = new URLSearchParams({ - source: 'upsell', - q: '*:*', - fq: `id:${idToAdd}` - }); - + 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)) { @@ -285,6 +334,15 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant } } + // Siapkan object sementara untuk generate Label (Memanfaatkan Extract Attribute) + const tempVar = { + code: codeToAdd, + name: nameToAdd, + displayName: searchItem.displayName || nameToAdd, // Pastikan ada displayName untuk diparsing + attributes: searchItem.attributes || [] + }; + const initialLabel = getVariantLabel(tempVar); + const newProductEntry = { id: idToAdd, sku: idToAdd, @@ -296,9 +354,12 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant id: idToAdd, code: codeToAdd, name: nameToAdd, + displayName: searchItem.displayName || nameToAdd, // Simpan untuk varian sendiri price: priceToAdd, - image: imageToAdd - }] + image: imageToAdd, + attributes: searchItem.attributes || [] + }], + inputValue: initialLabel }; setProducts((prev) => { @@ -330,8 +391,10 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant 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, // Wajib disimpan agar extractAttribute bisa bekerja price: s.lowestPrice?.price || s.priceTier1V2F || 0, - image: s.image || s.imageS + image: s.image || s.imageS, + attributes: [] // Biarkan kosong, nanti dihandle extractAttribute via displayName })); allVariants.sort((a: any, b: any) => @@ -410,16 +473,88 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant </Text> </Box> - <Select - size="sm" borderRadius="md" fontSize="xs" - value={product.id} - onChange={(e) => handleVariantChange(index, e.target.value)} - isDisabled={false} bg="white" - > - {product.variants && product.variants.map((v: any) => ( - <option key={v.id} value={v.id}>{v.code}</option> - ))} - </Select> + {/* --- AUTOCOMPLETE DROPDOWN (FINAL FIX) --- */} + <Box w="100%"> + <AutoComplete + openOnFocus + disableFilter={disableVariantFilter} + onChange={(val) => handleVariantChange(index, val)} + value={product.inputValue} + > + <InputGroup size="sm"> + <AutoCompleteInput + variant="outline" + fontSize="xs" + 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> + + {/* Dropdown List dengan Style Information.tsx */} + <AutoCompleteList fontSize="xs" maxH="250px" overflowY="auto" p={0}> + {product.variants && product.variants.map((v: any, vIdx: number) => { + + // Gunakan Helper extractAttribute untuk mendapatkan teks spesifikasi + // Ini akan bekerja baik untuk Slot 1 (pakai attributes) + // maupun Slot Search (parsing displayName) + 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" // Style dashed border + borderColor="gray.200" + > + <Flex justify="space-between" align="start" w="100%"> + {/* KIRI: KODE & ATRIBUT */} + <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> + + {/* KANAN: HARGA */} + <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> <HStack spacing={2}> <IconButton |
