summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFIN-IT_AndriFP <andrifebriyadiputra@gmail.com>2025-12-01 11:31:30 +0700
committerFIN-IT_AndriFP <andrifebriyadiputra@gmail.com>2025-12-01 11:31:30 +0700
commit825d86bb50f48f9a21d740d474c0dddee858dffb (patch)
tree023c9412c4c451aee45e54fe1d987e6220a8a6c5
parenta5e695f82e03577cc85c4a1dded9f6021f0235fc (diff)
(andri) show upsells product from magento in similarbottom product
-rw-r--r--src-migrate/modules/product-detail/components/ProductDetail.tsx183
-rw-r--r--src-migrate/modules/product-detail/components/SimilarBottom.tsx55
-rw-r--r--src-migrate/services/product.ts38
-rw-r--r--src/pages/api/magento-product.ts35
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);