summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--public/images/estimasi.svg3
-rw-r--r--public/images/garansi.svg5
-rw-r--r--public/images/produk_asli.svg3
-rw-r--r--src-migrate/modules/product-detail/components/Information.tsx114
-rw-r--r--src-migrate/modules/product-detail/components/ProductDetail.tsx453
-rw-r--r--src-migrate/modules/product-detail/components/SimilarBottom.tsx55
-rw-r--r--src-migrate/modules/product-detail/components/SimilarSide.tsx74
-rw-r--r--src-migrate/services/product.ts52
-rw-r--r--src/pages/api/magento-product.ts136
-rw-r--r--src/pages/api/shop/search.js230
10 files changed, 796 insertions, 329 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/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/Information.tsx b/src-migrate/modules/product-detail/components/Information.tsx
index a7a58cbc..b7d3401e 100644
--- a/src-migrate/modules/product-detail/components/Information.tsx
+++ b/src-migrate/modules/product-detail/components/Information.tsx
@@ -11,12 +11,11 @@ import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import currencyFormat from '@/core/utils/currencyFormat';
-import { InputGroup, InputRightElement, Spinner } from '@chakra-ui/react';
+import { InputGroup, InputRightElement, SimpleGrid, Flex, Text, Box } from '@chakra-ui/react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import Image from 'next/image';
import { formatToShortText } from '~/libs/formatNumber';
import { createSlug } from '~/libs/slug';
-import { getVariantSLA } from '~/services/productVariant';
import { IProductDetail } from '~/types/product';
import { useProductDetail } from '../stores/useProductDetail';
import useVariant from '../hook/useVariant';
@@ -30,7 +29,7 @@ type Props = {
};
const Information = ({ product }: Props) => {
- const { selectedVariant, setSelectedVariant, setSla, setActive, sla } =
+ const { selectedVariant, setSelectedVariant, setSla, sla } =
useProductDetail();
const [inputValue, setInputValue] = useState<string | null>(
@@ -45,15 +44,6 @@ const Information = ({ product }: Props) => {
const variantId = selectedVariant?.id;
const { slaVariant, isLoading } = useVariant({ variantId });
- // let variantOptions = product?.variants;
-
- // const querySLA = useQuery<IProductVariantSLA>({
- // queryKey: ['variant-sla', selectedVariant?.id],
- // queryFn: () => getVariantSLA(selectedVariant?.id),
- // enabled: !!selectedVariant?.id,
- // });
- // const sla = querySLA?.data;
-
useEffect(() => {
if (selectedVariant) {
setInputValue(
@@ -66,14 +56,13 @@ const Information = ({ product }: Props) => {
}, [selectedVariant]);
useEffect(() => {
- if (isLoading){
+ if (isLoading) {
setSla(null);
}
if (slaVariant) {
setSla(slaVariant);
}
- }, [slaVariant, isLoading]);
-
+ }, [slaVariant, isLoading, setSla]);
const handleOnChange = (vals: any) => {
setDisableFilter(true);
@@ -98,6 +87,15 @@ const Information = ({ product }: Props) => {
setInputValue(e.target.value);
};
+ // STYLE CUSTOM UNTUK BARIS (Item Code, dll)
+ const rowStyle = {
+ backgroundColor: '#ffffff',
+ fontSize: '13px',
+ borderBottom: '1px dashed #e2e8f0',
+ padding: '8px 0',
+ marginBottom: '0px'
+ };
+
return (
<div className={style['wrapper']}>
<div className='realtive mb-5'>
@@ -183,12 +181,15 @@ const Information = ({ product }: Props) => {
</AutoComplete>
</div>
- <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
@@ -217,29 +218,78 @@ 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>
- )}
- {!isLoading && <div className={style['value']}>{sla?.sla_date}</div>}
+
+ {/* === DETAIL INFORMASI PRODUK (Updated Layout) === */}
+ <div className="mt-6 border-t pt-4">
+ <h2 className="font-bold text-gray-800 text-sm mb-4">Detail Informasi Produk</h2>
+
+ {/* Perubahan: Spacing diperbesar menjadi 10 agar estimasi bergeser ke kanan */}
+ <SimpleGrid columns={{ base: 1, md: 3 }} spacing={10}>
+ {/* 1. Distributor Resmi */}
+ <Flex align="center" className="gap-3">
+ <img
+ src="/images/produk_asli.svg"
+ alt="Distributor Resmi"
+ className="w-10 h-10 shrink-0"
+ />
+ <Box>
+ <Text fontSize="11px" color="gray.500" lineHeight="short" mb="1px">Distributor Resmi</Text>
+ <Text fontSize="12px" fontWeight="bold" color="gray.800" lineHeight="short">Jaminan Produk Asli</Text>
+ </Box>
+ </Flex>
+
+ {/* 2. Estimasi Penyiapan */}
+ <Flex align="center" className="gap-3">
+ <img
+ src="/images/estimasi.svg"
+ alt="Estimasi Penyiapan"
+ className="w-9 h-9 shrink-0"
+ />
+ <Box>
+ <Text fontSize="11px" color="gray.500" lineHeight="short" mb="1px">Estimasi Penyiapan</Text>
+ {isLoading ? (
+ <Skeleton height="12px" width="60px" mt="2px" />
+ ) : (
+ <Text fontSize="12px" fontWeight="bold" color="gray.800" lineHeight="short">
+ {sla?.sla_date || '3 - 7 Hari'}
+ </Text>
+ )}
+ </Box>
+ </Flex>
+
+ {/* 3. Garansi Produk */}
+ <Flex align="center" className="gap-3">
+ <img
+ src="/images/garansi.svg"
+ alt="Garansi Produk"
+ className="w-10 h-10 shrink-0"
+ />
+ <Box>
+ <Text fontSize="11px" color="gray.500" lineHeight="short" mb="1px">Garansi Produk</Text>
+ <Text fontSize="12px" fontWeight="bold" color="gray.800" lineHeight="short">24 Bulan</Text>
+ </Box>
+ </Flex>
+ </SimpleGrid>
</div>
+
</div>
);
};
-export default Information;
+export default Information; \ 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 24af9749..9cd205e8 100644
--- a/src-migrate/modules/product-detail/components/ProductDetail.tsx
+++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx
@@ -4,13 +4,34 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState, UIEvent } 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
+} 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';
@@ -45,6 +66,14 @@ const ProductDetail = ({ product }: Props) => {
const { isDesktop, isMobile } = useDevice();
const router = useRouter();
const [auth, setAuth] = useState<any>(null);
+
+ // State Data dari Magento
+ const [specsMatrix, setSpecsMatrix] = useState<any[]>([]);
+ const [upsellIds, setUpsellIds] = useState<number[]>([]);
+ const [relatedIds, setRelatedIds] = useState<number[]>([]);
+
+ const [loadingSpecs, setLoadingSpecs] = useState(false);
+
useEffect(() => {
try {
setAuth(getAuth() ?? null);
@@ -61,8 +90,8 @@ const ProductDetail = ({ product }: Props) => {
activeVariantId,
setIsApproval,
isApproval,
+ selectedVariant,
setSelectedVariant,
- setSla,
} = useProductDetail();
useEffect(() => {
@@ -83,15 +112,164 @@ const ProductDetail = ({ product }: Props) => {
setAskAdminUrl(createdAskUrl);
}, [router.asPath, product.manufacture.name, product.name, setAskAdminUrl]);
+ // =========================================================================
+ // 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 processed = processMatrixData(data.specsMatrix);
+ setSpecsMatrix(processed);
+ } else {
+ setSpecsMatrix([]);
+ }
+
+ // 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 (Separator ':')
+ // =========================================================================
+ const processMatrixData = (rawMatrix: any[]) => {
+ const groups: any = {};
+ const result: any[] = [];
+
+ rawMatrix.forEach(item => {
+ // Cek Label: "Group Name : Sub Label"
+ 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 // Override label jadi pendek
+ });
+
+ } 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();
+
+ // URL Link
+ 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>
+ );
+ }
+
+ // HTML
+ if (strVal.includes('<') && strVal.includes('>')) {
+ return (
+ <div
+ className="prose prose-sm text-gray-700"
+ dangerouslySetInnerHTML={{ __html: strVal }}
+ />
+ );
+ }
+
+ // Teks Biasa
+ return strVal;
+ };
+
const allImages = (() => {
const arr: string[] = [];
@@ -136,7 +314,8 @@ const ProductDetail = ({ product }: Props) => {
const scrollToIndex = (i: number) => {
const el = sliderRef.current;
if (!el) return;
- el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' });
+ const elRef = sliderRef.current;
+ elRef.scrollTo({ left: i * elRef.clientWidth, behavior: 'smooth' });
setCurrentIdx(i);
setMainImage(allImages[i] || '');
};
@@ -176,93 +355,39 @@ 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
- ref={sliderRef}
- onScroll={handleMobileScroll}
- className='flex overflow-x-auto snap-x snap-mandatory scroll-smooth no-scrollbar'
- style={{
- scrollBehavior: 'smooth',
- msOverflowStyle: 'none',
- scrollbarWidth: 'none',
- }}
- >
+ <div ref={sliderRef} onScroll={handleMobileScroll} className='flex overflow-x-auto snap-x snap-mandatory scroll-smooth no-scrollbar' style={{ scrollBehavior: 'smooth', msOverflowStyle: 'none', scrollbarWidth: 'none' }}>
{allImages.length > 0 ? (
allImages.map((img, i) => (
- <div
- key={i}
- className='w-full flex-shrink-0 snap-center flex justify-center items-center'
- >
- {/* gambar diperkecil */}
- <img
- src={img}
- alt={`Gambar ${i + 1}`}
- className='w-[85%] aspect-square object-contain'
- onError={(e) => {
- (e.target as HTMLImageElement).src =
- '/images/noimage.jpeg';
- }}
- />
+ <div key={i} className='w-full flex-shrink-0 snap-center flex justify-center items-center'>
+ <img src={img} alt={`Gambar ${i + 1}`} className='w-[85%] aspect-square object-contain' onError={(e) => { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} />
</div>
))
) : (
<div className='w-full flex-shrink-0 snap-center flex justify-center items-center'>
- <img
- src={mainImage || '/images/noimage.jpeg'}
- alt='Gambar produk'
- className='w-[85%] aspect-square object-contain'
- />
+ <img src={mainImage || '/images/noimage.jpeg'} alt='Gambar produk' className='w-[85%] aspect-square object-contain' />
</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'
- }`}
- onClick={() => scrollToIndex(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'}`} onClick={() => scrollToIndex(i)} />
))}
</div>
)}
</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'
- }`}
- onClick={() => setMainImage(img)}
- >
- <img
- src={img}
- alt={`Thumbnail ${index + 1}`}
- className='w-full h-full object-cover rounded-sm'
- loading='lazy'
- onError={(e) => {
- (e.target as HTMLImageElement).src =
- '/images/noimage.jpeg';
- }}
- />
+ <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'}`} onClick={() => setMainImage(img)}>
+ <img src={img} alt={`Thumbnail ${index + 1}`} className='w-full h-full object-cover rounded-sm' loading='lazy' onError={(e) => { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} />
</div>
))}
</div>
@@ -271,7 +396,6 @@ const ProductDetail = ({ product }: Props) => {
</>
)}
</div>
- {/* <<=== TUTUP kolom kiri */}
{/* ===== Kolom kanan: info ===== */}
{isDesktop && (
@@ -298,13 +422,8 @@ const ProductDetail = ({ product }: Props) => {
<div className='md:w-8/12 px-4 md:pl-6 relative'>
{!hasPrice && (
<div className='bg-red-50 p-2 py-1.5 border-b border-red-500 flex gap-1 items-center w-screen relative left-1/2 right-1/2 -translate-x-1/2'>
- <AlertTriangle
- size={18}
- className='text-red-600 shrink-0 mx-2'
- />
- <h1 className='text-red-600 font-normal text-h-sm'>
- Maaf untuk saat ini Produk yang anda cari tidak tersedia
- </h1>
+ <AlertTriangle size={18} className='text-red-600 shrink-0 mx-2' />
+ <h1 className='text-red-600 font-normal text-h-sm'>Maaf untuk saat ini Produk yang anda cari tidak tersedia</h1>
</div>
)}
<h1 className={style['title']}>{product.name}</h1>
@@ -324,102 +443,146 @@ const ProductDetail = ({ product }: Props) => {
<div className='h-4 md:h-10' />
{!!activeVariantId && !isApproval && (
- <ProductPromoSection
- product={product}
- productId={activeVariantId}
- />
+ <ProductPromoSection product={product} productId={activeVariantId} />
)}
<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'>
+ <div className={style['description']} dangerouslySetInnerHTML={{ __html: !product.description || product.description === '<p><br></p>' ? '<p>Lorem ipsum dolor sit amet.</p>' : product.description, }} />
+ </div>
+ </TabPanel>
+
+ {/* SPESIFIKASI (LOGIKA GROUPING + RATA TENGAH) */}
+ <TabPanel px={0} py={2}>
+ <Box border="1px solid" borderColor="gray.200" borderRadius="sm" overflowX="auto">
+ {loadingSpecs ? (
+ <Center py={6}><Spinner color='red.500' /></Center>
+ ) : specsMatrix.length > 0 ? (
+ (() => {
+ const isSingleVariant = product.variants.length === 1;
+ const globalAlign = isSingleVariant ? "left" : "center";
+
+ return (
+ <Table variant="simple" size="md">
+ <Thead bg="red.600">
+ <Tr>
+ <Th width={isSingleVariant ? "30%" : "20%"} borderColor="whiteAlpha.300" color="white" fontSize="sm" textTransform="none" verticalAlign="middle">
+ Spesifikasi
+ </Th>
+ {product.variants.map(v => (
+ <Th key={v.id} borderColor="whiteAlpha.300" color="white" textAlign={globalAlign} fontSize="sm" textTransform="none" verticalAlign="middle">
+ {isSingleVariant ? 'Detail' : (v.attributes && v.attributes.length > 0 ? v.attributes.join(' - ') : v.code)}
+ </Th>
+ ))}
+ </Tr>
+ </Thead>
+ <Tbody>
+ {specsMatrix.map((row, i) => {
+
+ // CASE 1: GROUPING (Label punya ' : ')
+ if (row.type === 'group') {
+ return (
+ <Tr key={i}>
+ {/* Header Group Kiri */}
+ <Td fontWeight="bold" borderColor="gray.200" fontSize="sm" verticalAlign="middle">{row.label}</Td>
+
+ {/* Content Group Kanan */}
+ {product.variants.map(v => (
+ <Td key={v.id} borderColor="gray.200" textAlign={globalAlign} fontSize="sm" verticalAlign="middle">
+ <div className={`inline-block text-left`}>
+ <div className="flex flex-col gap-0">
+ {row.children.map((child: any, idx: number) => {
+ const rawVal = child.values[v.id];
+ if (!rawVal || rawVal === '-') return null;
+
+ return (
+ <div key={idx} className="grid grid-cols-[auto_auto] gap-x-2">
+ <span className="font-semibold text-gray-600 whitespace-nowrap">
+ {child.label}:
+ </span>
+ <span>{renderSpecValue(rawVal)}</span>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </Td>
+ ))}
+ </Tr>
+ );
+ }
+
+ // CASE 2: SINGLE ITEM
+ return (
+ <Tr key={i}>
+ <Td fontWeight="bold" borderColor="gray.200" fontSize="sm" verticalAlign="middle">{row.label}</Td>
+ {product.variants.map(v => {
+ const rawValue = row.values[v.id] || '-';
+ return (
+ <Td key={v.id} borderColor="gray.200" textAlign={globalAlign} fontSize="sm" verticalAlign="middle">
+ {renderSpecValue(rawValue)}
+ </Td>
+ );
+ })}
+ </Tr>
+ );
+ })}
+ </Tbody>
+ </Table>
+ );
+ })()
+ ) : (
+ <Box p={4} color="gray.500" fontSize="sm"><Text>Spesifikasi teknis belum tersedia.</Text></Box>
+ )}
+ </Box>
+ </TabPanel>
+
+ {/* DETAIL LAINNYA */}
+ <TabPanel px={0} py={6}><p className="text-gray-500 text-sm">Informasi tambahan belum tersedia.</p></TabPanel>
+ </TabPanels>
+ </Tabs>
</div>
</div>
</div>
+ {/* ... (Bagian Sidebar & Bottom SAMA) ... */}
{isDesktop && (
<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>
-
- <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>
- )}
+ <div className='flex gap-x-5 items-center justify-center py-4'>
+ {/* ... Buttons ... */}
</div>
-
<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>
</>
);
};
-export default ProductDetail;
+export default ProductDetail; \ No newline at end of file
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/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/pages/api/magento-product.ts b/src/pages/api/magento-product.ts
new file mode 100644
index 00000000..297f0ebc
--- /dev/null
+++ b/src/pages/api/magento-product.ts
@@ -0,0 +1,136 @@
+// 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 = 'vxrtcjvztv1icgjzsui45de9kmwlz0lf';
+ const baseUrl = 'https://pimdev.1211.my.id/rest/V1';
+
+ 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: [] });
+ }
+
+ // =====================================================================
+ // 2. BUILD SPECS MATRIX
+ // Kita butuh daftar semua atribut unik (z_*) dari seluruh varian
+ // =====================================================================
+
+ // 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: {} // Key = SKU/ID Variant, Value = Isi Atribut
+ };
+
+ items.forEach((p: any) => {
+ const attr = p.custom_attributes.find((a: any) => a.attribute_code === code);
+ // Simpan value berdasarkan SKU (ID Variant dari Odoo)
+ row.values[p.sku] = attr ? attr.value : '-';
+ });
+
+ matrix.push(row);
+ });
+
+ // =====================================================================
+ // 3. AMBIL LINKS (UPSELL & RELATED) DARI MAIN VARIANT SAJA
+ // =====================================================================
+ // Cari data milik varian utama (varian pertama)
+ 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
+ });
+
+ } 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 42d16100..7d4adfcb 100644
--- a/src/pages/api/shop/search.js
+++ b/src/pages/api/shop/search.js
@@ -12,7 +12,7 @@ export default async function handler(req, res) {
priceTo = 0,
orderBy = '',
operation = 'AND',
- fq = '',
+ fq = '', // bisa berupa string atau array
limit = 30,
source = '',
} = req.query;
@@ -20,107 +20,119 @@ export default async function handler(req, res) {
let { stock = '' } = req.query;
// ============================================================
- // SITEMAP
+ // LOGIC KHUSUS UPSELL (Simple & Direct)
// ============================================================
- if (source === 'sitemap') {
+ if (source === 'upsell') {
try {
- const offset = (page - 1) * limit;
+ // 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}`,
-
- // // ❌ EXCLUDE PROMOTION
- // 'fq=-(name_s:*promotion* OR display_name_s:*promotion* OR variants_name_t:*promotion*)',
-
- // // ❌ EXCLUDE DUMMY PRODUCT
- // 'fq=-(name_s:*dummy* OR display_name_s:*dummy* OR variants_name_t:*dummy* OR default_code_s:A.*)',
+ // PENTING: SEARCH DI CORE 'VARIANTS'
+ const solrUrl = process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&');
- // 'fl=product_id_i,name_s,default_code_s,image_s,category_name',
- // 'wt=json',
- // 'omitHeader=true',
- // ];
+ const result = await axios(solrUrl);
- 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(
+ // 1. Mapping dasar
+ const mappedProducts = productMappingSolr(
result.data.response.docs,
false
);
- delete result.data.response.docs;
+ // 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);
- result.data = camelcaseObjectDeep(result.data);
+ } catch (e) {
+ console.error('[UPSELL ERROR]', e.response?.data || e.message);
+ return res.status(200).json({ response: { products: [], numFound: 0 } });
+ }
+ }
+ // ============================================================
+ // SITEMAP (Biarkan tetap sama)
+ // ============================================================
+ 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('&');
+ const result = await axios(solrUrl, { timeout: 25000 });
+ result.data.response.products = productMappingSolr(result.data.response.docs, 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 (LOGIKA LAMA)
// ============================================================
let paramOrderBy = '';
switch (orderBy) {
- case 'flashsale-discount-desc':
- paramOrderBy += 'flashsale_discount_f DESC';
- break;
- case 'price-asc':
- paramOrderBy += 'price_tier1_v2_f ASC';
- break;
- case 'price-desc':
- paramOrderBy += 'price_tier1_v2_f DESC';
- break;
- case 'popular':
- paramOrderBy += 'product_rating_f DESC, search_rank_i DESC,';
- break;
- case 'popular-weekly':
- paramOrderBy += 'search_rank_weekly_i DESC';
- break;
- case 'stock':
- paramOrderBy += 'product_rating_f DESC, stock_total_f DESC';
- break;
- case 'flashsale-price-asc':
- paramOrderBy += 'flashsale_price_f ASC';
- break;
- default:
- paramOrderBy += '';
- break;
+ case 'flashsale-discount-desc': paramOrderBy += 'flashsale_discount_f DESC'; break;
+ case 'price-asc': paramOrderBy += 'price_tier1_v2_f ASC'; break;
+ case 'price-desc': paramOrderBy += 'price_tier1_v2_f DESC'; break;
+ case 'popular': paramOrderBy += 'product_rating_f DESC, search_rank_i DESC,'; break;
+ case 'popular-weekly': paramOrderBy += 'search_rank_weekly_i DESC'; break;
+ case 'stock': paramOrderBy += 'product_rating_f DESC, stock_total_f DESC'; break;
+ case 'flashsale-price-asc': paramOrderBy += 'flashsale_price_f ASC'; break;
+ default: paramOrderBy += ''; break;
}
let checkQ = q.trim().split(/[\s\+\-\!\(\)\{\}\[\]\^"~\*\?:\\\/]+/);
let newQ = escapeSolrQuery(q);
- const formattedQuery = `(${newQ
- .split(' ')
- .map((term) => (term.length < 2 ? term : `${term}*`))
- .join(' ')})`;
-
- const mm =
- checkQ.length > 2
- ? checkQ.length > 5
- ? '55%'
- : '85%'
- : `${checkQ.length}`;
+ const formattedQuery = `(${newQ.split(' ').map((term) => (term.length < 2 ? term : `${term}*`)).join(' ')})`;
+ const mm = checkQ.length > 2 ? (checkQ.length > 5 ? '55%' : '85%') : `${checkQ.length}`;
const filterQueries = [
'-publish_b:false',
@@ -128,23 +140,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,16 +172,16 @@ 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
- }]`
- );
+ parameter.push(`fq=price_tier1_v2_f:[${priceFrom || '*'} TO ${priceTo || '*'}]`);
}
let { auth } = req.cookies;
@@ -183,49 +191,28 @@ export default async function handler(req, res) {
}
if (brand) {
- const brandExpr = brand
- .split(',')
- .map(
- (manufacturer) =>
- `manufacture_name:"${encodeURIComponent(manufacturer)}"`
- )
- .join(' OR ');
+ const brandExpr = brand.split(',').map(m => `manufacture_name:"${encodeURIComponent(m)}"`).join(' OR ');
parameter.push(`fq={!tag=brand}(${brandExpr})`);
}
if (category) {
- const catExpr = category
- .split(',')
- .map((cat) => `category_name:"${encodeURIComponent(cat)}"`)
- .join(' OR ');
+ const catExpr = category.split(',').map(c => `category_name:"${encodeURIComponent(c)}"`).join(' OR ');
parameter.push(`fq={!tag=cat}(${catExpr})`);
}
if (stock) parameter.push(`fq=stock_total_f:(1 TO *)`);
- if (typeof fq === 'string') parameter.push(`fq=${encodeURIComponent(fq)}`);
-
- if (Array.isArray(fq))
- parameter = parameter.concat(
- fq.map((val) => `fq=${encodeURIComponent(val)}`)
- );
-
- const solrUrl =
- process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&');
-
- const result = await axios(solrUrl);
+ // SEARCH NORMAL: DEFAULT KE CORE 'PRODUCT'
+ const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&');
try {
+ const result = await axios(solrUrl);
result.data.response.products = productMappingSolr(
result.data.response.docs,
auth?.pricelist || false
);
- result.data.responseHeader.params.start = parseInt(
- result.data.responseHeader.params.start
- );
- result.data.responseHeader.params.rows = parseInt(
- result.data.responseHeader.params.rows
- );
+ result.data.responseHeader.params.start = parseInt(result.data.responseHeader.params.start);
+ result.data.responseHeader.params.rows = parseInt(result.data.responseHeader.params.rows);
delete result.data.response.docs;
result.data = camelcaseObjectDeep(result.data);
res.status(200).json(result.data);
@@ -236,17 +223,8 @@ export default async function handler(req, res) {
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(' ');
-};
+ return words.map((word) => specialChars.test(word) ? word.replace(specialChars, '\\$1') : word).join(' ');
+}; \ No newline at end of file