diff options
| author | FIN-IT_AndriFP <andrifebriyadiputra@gmail.com> | 2025-12-01 11:31:30 +0700 |
|---|---|---|
| committer | FIN-IT_AndriFP <andrifebriyadiputra@gmail.com> | 2025-12-01 11:31:30 +0700 |
| commit | 825d86bb50f48f9a21d740d474c0dddee858dffb (patch) | |
| tree | 023c9412c4c451aee45e54fe1d987e6220a8a6c5 | |
| parent | a5e695f82e03577cc85c4a1dded9f6021f0235fc (diff) | |
(andri) show upsells product from magento in similarbottom product
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductDetail.tsx | 183 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/SimilarBottom.tsx | 55 | ||||
| -rw-r--r-- | src-migrate/services/product.ts | 38 | ||||
| -rw-r--r-- | src/pages/api/magento-product.ts | 35 |
4 files changed, 187 insertions, 124 deletions
diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index b0950194..5f930117 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -22,11 +22,13 @@ import { Text } from '@chakra-ui/react'; +// Import Icons import { AlertTriangle, MessageCircleIcon, Share2Icon, } from 'lucide-react'; + import { LazyLoadComponent } from 'react-lazy-load-image-component'; import useDevice from '@/core/hooks/useDevice'; @@ -62,8 +64,9 @@ const ProductDetail = ({ product }: Props) => { const router = useRouter(); const [auth, setAuth] = useState<any>(null); - // State untuk Spesifikasi dari Magento - const [specs, setSpecs] = useState<{ label: string; value: string }[]>([]); + // State Data dari Magento + const [specs, setSpecs] = useState<{ code: string; label: string; value: string }[]>([]); + const [upsellIds, setUpsellIds] = useState<number[]>([]); const [loadingSpecs, setLoadingSpecs] = useState(false); const [errorSpecs, setErrorSpecs] = useState(false); @@ -105,6 +108,9 @@ const ProductDetail = ({ product }: Props) => { setAskAdminUrl(createdAskUrl); }, [router.asPath, product.manufacture.name, product.name, setAskAdminUrl]); + // ========================================================================= + // LOGIC INISIALISASI VARIANT (HANDLE NAVIGASI) + // ========================================================================= useEffect(() => { if (typeof auth === 'object') { setIsApproval(auth?.feature?.soApproval); @@ -112,60 +118,95 @@ const ProductDetail = ({ product }: Props) => { const variantInit = product?.variants?.find((variant) => variant.is_in_bu) || product?.variants?.[0]; + setSelectedVariant(variantInit); - }, []); + + // Reset data Magento saat produk berubah + setSpecs([]); + setUpsellIds([]); + + }, [product, auth]); // ========================================================================= - // LOGIC FETCH: MENGGUNAKAN ID VARIANT + // LOGIC FETCH: SPECS & UPSELLS // ========================================================================= useEffect(() => { - const fetchMagentoSpecs = async () => { - // MENGGUNAKAN ID VARIANT SESUAI REQUEST - const skuToFetch = selectedVariant?.id; + const fetchMagentoData = async () => { + // Gunakan ID Variant (SKU Odoo) + const idToFetch = selectedVariant?.id; - if (!skuToFetch) return; + if (!idToFetch) return; setLoadingSpecs(true); setErrorSpecs(false); try { - console.log("Fetching Magento specs via Proxy for Variant ID:", skuToFetch); + console.log("Fetching Magento data via Proxy for ID:", idToFetch); - // Pastikan dikonversi ke string - const endpoint = `/api/magento-product?sku=${encodeURIComponent(String(skuToFetch))}`; + const endpoint = `/api/magento-product?sku=${encodeURIComponent(String(idToFetch))}`; const response = await fetch(endpoint, { method: 'GET', - headers: { - 'Content-Type': 'application/json', - } + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { - console.warn(`Spec API status: ${response.status}`); + console.warn(`Magento API status: ${response.status}`); setSpecs([]); + setUpsellIds([]); return; } const data = await response.json(); + // 1. Simpan Data Spesifikasi if (data.specs && Array.isArray(data.specs)) { setSpecs(data.specs); } else { setSpecs([]); } + // 2. Simpan Data Up-Sells (ID) + if (data.upsell_ids && Array.isArray(data.upsell_ids)) { + setUpsellIds(data.upsell_ids); + } else { + setUpsellIds([]); + } + } catch (error) { - console.error("Gagal mengambil data spesifikasi:", error); + console.error("Gagal mengambil data Magento:", error); setErrorSpecs(true); setSpecs([]); + setUpsellIds([]); } finally { setLoadingSpecs(false); } }; - fetchMagentoSpecs(); - }, [selectedVariant]); + fetchMagentoData(); + }, [selectedVariant, product.id]); + + + // ========================================================================= + // HELPER: RENDER SPEC VALUE (SIMPLE TEXT/HTML ONLY) + // ========================================================================= + const renderSpecValue = (item: { code: string; label: string; value: string }) => { + const val = item.value; + if (!val) return '-'; + + // Cek apakah mengandung tag HTML sederhana (<p>, <a>, <ul>, dll) + if (val.includes('<') && val.includes('>')) { + return ( + <div + className="prose prose-sm text-gray-700" + dangerouslySetInnerHTML={{ __html: val }} + /> + ); + } + + // Default: Teks Biasa + return val; + }; const allImages = (() => { @@ -404,63 +445,32 @@ const ProductDetail = ({ product }: Props) => { <div className='h-0 md:h-6' /> - {/* === SECTION TABS: DESKRIPSI & SPESIFIKASI === */} + {/* === SECTION TABS === */} <div className={style['section-card']}> <Tabs variant="unstyled"> - {/* Header Tabs */} <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} + _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} + _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} + _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> - {/* PANEL 1: DESKRIPSI */} + {/* DESKRIPSI */} <TabPanel px={0} py={6}> <div className='overflow-x-auto text-sm text-gray-700'> <div @@ -468,24 +478,18 @@ const ProductDetail = ({ product }: Props) => { dangerouslySetInnerHTML={{ __html: !product.description || product.description === '<p><br></p>' - ? '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>' + ? '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>' : product.description, }} /> </div> </TabPanel> - {/* PANEL 2: SPESIFIKASI DARI MAGENTO */} + {/* SPESIFIKASI */} <TabPanel px={0} py={2}> <Box border="1px solid" borderColor="gray.200" borderRadius="sm"> {loadingSpecs ? ( - <Center py={6}> - <Spinner color='red.500' /> - </Center> - ) : errorSpecs ? ( - <Box p={4} color="red.500" fontSize="sm"> - Gagal memuat data spesifikasi. Silakan coba lagi nanti. - </Box> + <Center py={6}><Spinner color='red.500' /></Center> ) : specs.length > 0 ? ( <Table variant="simple" size="md"> <Tbody> @@ -495,7 +499,7 @@ const ProductDetail = ({ product }: Props) => { {item.label} </Td> <Td verticalAlign="top" borderColor="gray.200"> - {item.value} + {renderSpecValue(item)} </Td> </Tr> ))} @@ -503,13 +507,13 @@ const ProductDetail = ({ product }: Props) => { </Table> ) : ( <Box p={4} color="gray.500" fontSize="sm"> - <Text>Spesifikasi teknis belum tersedia untuk varian ID: <b>{selectedVariant?.id}</b></Text> + <Text>Spesifikasi teknis belum tersedia.</Text> </Box> )} </Box> </TabPanel> - {/* PANEL 3: DETAIL LAINNYA */} + {/* DETAIL LAINNYA */} <TabPanel px={0} py={6}> <p className="text-gray-500 text-sm">Informasi tambahan belum tersedia.</p> </TabPanel> @@ -524,64 +528,31 @@ const ProductDetail = ({ product }: Props) => { <div className='md:w-3/12'> <PriceAction product={product} /> <div className='flex gap-x-5 items-center justify-center'> - <Button - as={Link} - href={askAdminUrl} - variant='link' - target='_blank' - colorScheme='gray' - leftIcon={<MessageCircleIcon size={18} />} - isDisabled={!hasPrice} - > - Ask Admin - </Button> - + <Button as={Link} href={askAdminUrl} variant='link' target='_blank' colorScheme='gray' leftIcon={<MessageCircleIcon size={18} />} isDisabled={!hasPrice}>Ask Admin</Button> <span>|</span> - <div className={hasPrice ? '' : 'opacity-40 pointer-events-none'}> <AddToWishlist productId={product.id} /> </div> - <span>|</span> - {canShare && ( - <RWebShare - data={{ - text: 'Check out this product', - title: `${product.name} - Indoteknik.com`, - url: - (process.env.NEXT_PUBLIC_SELF_HOST || '') + - (router?.asPath || '/'), - }} - > - <Button - variant='link' - colorScheme='gray' - leftIcon={<Share2Icon size={18} />} - isDisabled={!hasPrice} - > - Share - </Button> + <RWebShare data={{ text: 'Check out this product', title: `${product.name} - Indoteknik.com`, url: (process.env.NEXT_PUBLIC_SELF_HOST || '') + (router?.asPath || '/'), }}> + <Button variant='link' colorScheme='gray' leftIcon={<Share2Icon size={18} />} isDisabled={!hasPrice}>Share</Button> </RWebShare> )} </div> <div className='h-6' /> <div className={style['heading']}>Produk Serupa</div> - <div className='h-4' /> - <SimilarSide product={product} /> </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> 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/services/product.ts b/src-migrate/services/product.ts index 77b645f0..f77fc3ec 100644 --- a/src-migrate/services/product.ts +++ b/src-migrate/services/product.ts @@ -64,3 +64,41 @@ 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> => { + // Jika array ID kosong, kembalikan object kosong agar tidak error + if (!ids || ids.length === 0) { + return { + products: [], + num_found: 0, + num_found_exact: true, + start: 0 + }; + } + + // Buat query Solr format: product_id_i:(224102 OR 88019 OR ...) + const idQuery = ids.join(' OR '); + + const query = [ + `q=*`, // Query wildcard (ambil semua) + `fq=product_id_i:(${idQuery})`, // TAPI difilter hanya ID yang ada di list upsell + 'rows=20', + `source=upsell`, + ]; + + const url = `${SELF_HOST}/api/shop/search?${query.join('&')}`; + + return await fetch(url) + .then((res) => res.json()) + .then((res) => snakeCase(res.response)); +};
\ No newline at end of file diff --git a/src/pages/api/magento-product.ts b/src/pages/api/magento-product.ts index c906079e..c494b05d 100644 --- a/src/pages/api/magento-product.ts +++ b/src/pages/api/magento-product.ts @@ -1,3 +1,4 @@ +// pages/api/magento-product.ts import type { NextApiRequest, NextApiResponse } from 'next'; export default async function handler( @@ -48,7 +49,7 @@ export default async function handler( const data = await response.json(); // ===================================================================== - // TAMBAHAN: FETCH LABEL ATRIBUT (z_*) + // TAMBAHAN 1: FETCH LABEL ATRIBUT (z_*) // ===================================================================== let specsWithLabels: any[] = []; @@ -56,6 +57,7 @@ export default async function handler( // Cek apakah ada custom_attributes if (data.custom_attributes) { // Filter atribut yang kodenya dimulai dengan 'z' + // FIX: Menghapus filter 'Pakai link' agar MSDS tetap muncul const zAttributes = data.custom_attributes.filter((attr: any) => attr.attribute_code.startsWith('z') ); @@ -64,8 +66,8 @@ export default async function handler( specsWithLabels = await Promise.all( zAttributes.map(async (attr: any) => { try { - // Endpoint untuk ambil detail atribut (Label): /V1/products/attributes/{attributeCode} - const attrUrl = `${baseUrl}/attributes/${attr.attribute_code}`; + // Endpoint untuk ambil detail atribut (Label) + const attrUrl = `https://pimdev.1211.my.id/rest/V1/products/attributes/${attr.attribute_code}`; const attrRes = await fetch(attrUrl, { method: 'GET', @@ -77,8 +79,8 @@ export default async function handler( if (attrRes.ok) { const attrData = await attrRes.json(); - // AMBIL NILAI 'default_frontend_label' return { + code: attr.attribute_code, // FIX: Kirim code agar bisa dideteksi frontend (z_doc_) label: attrData.default_frontend_label || attr.attribute_code, value: attr.value }; @@ -87,11 +89,12 @@ export default async function handler( console.error(`Failed to fetch label for ${attr.attribute_code}`); } - // Fallback: Jika gagal ambil label, format manual dari kode + // Fallback: Format manual const fallbackLabel = attr.attribute_code - .substring(1).replace(/_/g, ' ').trim(); // z_size_ml -> size ml + .substring(1).replace(/_/g, ' ').trim(); return { + code: attr.attribute_code, // FIX: Kirim code di fallback juga label: fallbackLabel, value: attr.value }; @@ -99,10 +102,26 @@ export default async function handler( ); } - // Gabungkan data asli dengan data specs yang sudah ada labelnya + // ===================================================================== + // TAMBAHAN 2: AMBIL UP-SELLS (product_links) + // ===================================================================== + let upsellIds: number[] = []; + + if (data.product_links && Array.isArray(data.product_links)) { + upsellIds = data.product_links + // Filter hanya link type 'upsell' + .filter((link: any) => link.link_type === 'upsell') + // Ambil SKU (yang isinya ID Odoo) dan ubah ke number + .map((link: any) => Number(link.linked_product_sku)); + } + + // ===================================================================== + // RESPONSE GABUNGAN + // ===================================================================== const responseData = { ...data, - specs: specsWithLabels // Frontend tinggal pakai field ini + specs: specsWithLabels, // Data Spesifikasi (z_*) + upsell_ids: upsellIds // Data Upsell ID (product_links) }; res.status(200).json(responseData); |
