diff options
| author | Miqdad <ahmadmiqdad27@gmail.com> | 2025-10-28 12:26:03 +0700 |
|---|---|---|
| committer | Miqdad <ahmadmiqdad27@gmail.com> | 2025-10-28 12:26:03 +0700 |
| commit | 7f71d52e2e6e6e8ffb5ea2837be84c800d04ef95 (patch) | |
| tree | b7f687b5f3034adff037315513f2f216fc87328d /src-migrate | |
| parent | b3367a2d1882e0da9a366e1dfe07e9c9851989e3 (diff) | |
<Miqdad> canonical product detail
Diffstat (limited to 'src-migrate')
| -rw-r--r-- | src-migrate/components/seo.tsx | 87 | ||||
| -rw-r--r-- | src-migrate/libs/slug.ts | 35 | ||||
| -rw-r--r-- | src-migrate/pages/shop/product/[slug].tsx | 120 |
3 files changed, 170 insertions, 72 deletions
diff --git a/src-migrate/components/seo.tsx b/src-migrate/components/seo.tsx index 1e78ed4d..0b522859 100644 --- a/src-migrate/components/seo.tsx +++ b/src-migrate/components/seo.tsx @@ -1,32 +1,75 @@ -import { useRouter } from 'next/router' -import React from 'react' -import { NextSeo } from "next-seo" +import React from 'react'; +import { useRouter } from 'next/router'; +import { NextSeo } from 'next-seo'; import { MetaTag, NextSeoProps } from 'next-seo/lib/types'; export const Seo = (props: NextSeoProps) => { - const router = useRouter() + const router = useRouter(); - const additionalMetaTags: MetaTag[] = [ - { - property: 'fb:app_id', - content: '270830718811' - }, - { - property: 'fb:page_id', - content: '101759953569' - }, - ] + const { + canonical, + description, + additionalMetaTags: userMetaTags, + openGraph: userOpenGraph, + ...restProps + } = props; - if (!!props.additionalMetaTags) additionalMetaTags.push(...props.additionalMetaTags) + // base domain dari env, buang trailing slash biar rapi + const origin = (process.env.NEXT_PUBLIC_SELF_HOST || '').replace(/\/+$/, ''); + + // path sekarang, contoh: + // "/shop/category/bor?page=2&sort=popular" + const asPath = router.asPath || ''; + const [cleanPath] = asPath.split('?'); // "/shop/category/bor" + + const queryObj = router.query || {}; + + // deteksi kalo ini halaman search + // kalau route search lo beda, ganti prefix ini + const isSearchPage = cleanPath.startsWith('/search'); + + // fallback canonical buat halaman yang TIDAK ngasih canonical prop sendiri + const buildFallbackCanonical = () => { + if (isSearchPage) { + const q = queryObj.q; + if (q) { + // search punya intent unik per q, tapi kita buang param lain kayak page/sort + return origin + cleanPath + `?q=${encodeURIComponent(String(q))}`; + } + return origin + cleanPath; + } + + // kategori/brand/listing biasa -> tanpa query sama sekali + return origin + cleanPath; + }; + + // Prioritas final: + // 1. kalau page ngasih props.canonical -> pakai itu + // 2. kalau gak -> pakai fallback + const resolvedCanonical = canonical || buildFallbackCanonical(); + + // gabung meta default FB + custom + const mergedMetaTags: MetaTag[] = [ + { property: 'fb:app_id', content: '270830718811' }, + { property: 'fb:page_id', content: '101759953569' }, + ]; + if (userMetaTags && Array.isArray(userMetaTags)) { + mergedMetaTags.push(...userMetaTags); + } return ( <NextSeo defaultTitle='Indoteknik.com: B2B Industrial Supply & Solution' - canonical={process.env.NEXT_PUBLIC_SELF_HOST + router.asPath} - description={props.title} - {...props} - openGraph={{ siteName: 'Indoteknik.com', ...props.openGraph }} - additionalMetaTags={additionalMetaTags} + canonical={resolvedCanonical} + description={description} + {...restProps} + openGraph={{ + siteName: 'Indoteknik.com', + ...userOpenGraph, + }} + additionalMetaTags={mergedMetaTags} /> - ) -}
\ No newline at end of file + ); +}; + +export default Seo; diff --git a/src-migrate/libs/slug.ts b/src-migrate/libs/slug.ts index 5ab3b3dd..e8267ed2 100644 --- a/src-migrate/libs/slug.ts +++ b/src-migrate/libs/slug.ts @@ -3,21 +3,30 @@ import { toTitleCase } from './toTitleCase'; export const createSlug = ( prefix: string, name: string, - id: string, - withHost = false -) => { - const cleanName = name - .trim() - .replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-') - .toLowerCase(); - - let slug = `${cleanName}-${id}`; - const splitSlug = slug.split('-'); - const filterSlug = splitSlug.filter((x) => x !== ''); + id: string | number, + withHost: boolean = false +): string => { + name ||= ''; + + // pastikan id jadi string + const safeId = (id ?? '').toString(); - slug = `${prefix}${filterSlug.join('-')}`; + let slug = + name + ?.trim() + .replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-') // non alphanumeric -> "-" + .toLowerCase() + + '-' + + safeId; + + const splitSlug = slug.split('-'); + const filterSlugFromEmptyChar = splitSlug.filter((x) => x !== ''); + slug = prefix + filterSlugFromEmptyChar.join('-'); - if (withHost) slug = process.env.NEXT_PUBLIC_SELF_HOST + slug; + if (withHost) { + const host = (process.env.NEXT_PUBLIC_SELF_HOST || '').replace(/\/+$/, ''); + slug = host + slug; + } return slug; }; diff --git a/src-migrate/pages/shop/product/[slug].tsx b/src-migrate/pages/shop/product/[slug].tsx index 90658544..058e4832 100644 --- a/src-migrate/pages/shop/product/[slug].tsx +++ b/src-migrate/pages/shop/product/[slug].tsx @@ -1,71 +1,118 @@ -import { GetServerSideProps, NextPage } from 'next' -import React, { useEffect } from 'react' -import dynamic from 'next/dynamic' -import cookie from 'cookie' - -import { getProductById } from '~/services/product' -import { getIdFromSlug } from '~/libs/slug' -import { IProductDetail } from '~/types/product' - -import { Seo } from '~/components/seo' -import { useRouter } from 'next/router' -import { useProductContext } from '@/contexts/ProductContext' - -const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout'), { ssr: false }) -const ProductDetail = dynamic(() => import('~/modules/product-detail'), { ssr: false }) +import { GetServerSideProps, NextPage } from 'next'; +import React, { useEffect } from 'react'; +import dynamic from 'next/dynamic'; +import cookie from 'cookie'; + +import { getProductById } from '~/services/product'; +import { createSlug, getIdFromSlug } from '~/libs/slug'; // <-- tambahin createSlug +import { IProductDetail } from '~/types/product'; + +import { Seo } from '~/components/seo'; +import { useRouter } from 'next/router'; +import { useProductContext } from '@/contexts/ProductContext'; + +const BasicLayout = dynamic( + () => import('@/core/components/layouts/BasicLayout'), + { ssr: false } +); +const ProductDetail = dynamic(() => import('~/modules/product-detail'), { + ssr: false, +}); type PageProps = { - product: IProductDetail -} + product: IProductDetail; + canonicalPath: string; +}; -export const getServerSideProps: GetServerSideProps<PageProps & { canonicalPath: string }> = async (context) => { +export const getServerSideProps: GetServerSideProps<PageProps> = async ( + context +) => { const { slug } = context.query; + + // ambil cookie pricelist tier const cookieString = context.req.headers.cookie; const cookies = cookieString ? cookie.parse(cookieString) : {}; const auth = cookies?.auth ? JSON.parse(cookies.auth) : {}; const tier = auth?.pricelist || ''; + // ambil ID produk dari slug URL const productId = getIdFromSlug(slug as string); + + // fetch data produk dari backend lo const product = await getProductById(productId, tier); - // ❌ produk tidak ada → 404 + // hard guard: produk gak ada -> 404 if (!product) return { notFound: true }; - // ❌ tidak ada variants atau tidak ada yang harga > 0 → 404 - const hasValidVariant = Array.isArray(product.variants) - && product.variants.some(v => (v?.price?.price ?? 0) > 0); + // guard: gak ada varian harga valid -> 404 + const hasValidVariant = + Array.isArray(product.variants) && + product.variants.some((v) => (v?.price?.price ?? 0) > 0); + if (!hasValidVariant) return { notFound: true }; - // Canonical path aman untuk SSR (hindari router.asPath di server) - const canonicalPath = context.resolvedUrl || `/product/${slug}`; + // bikin canonical path yang BERSIH dan KONSISTEN dari data produk, + // bukan dari URL request user (jadi gak ikut ?utm_source, ?ref=, dsb) + const canonicalPath = createSlug( + '/shop/product/', // ganti ini sesuai prefix route produk lo yang SEBENARNYA + product?.name || '', + product?.id, + false // false = jangan include host di sini + ); - return { props: { product, canonicalPath } }; + return { + props: { + product, + canonicalPath, + }, + }; }; - -const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST - -const ProductDetailPage: NextPage<PageProps & { canonicalPath: string }> = ({ product, canonicalPath }) => { +const ProductDetailPage: NextPage<PageProps> = ({ product, canonicalPath }) => { const router = useRouter(); const { setProduct } = useProductContext(); - useEffect(() => { if (product) setProduct(product); }, [product, setProduct]); + // taruh product di context global lo + useEffect(() => { + if (product) setProduct(product); + }, [product, setProduct]); + + // rapihin origin biar gak double slash + const origin = (process.env.NEXT_PUBLIC_SELF_HOST || '').replace(/\/+$/, ''); + const pathClean = canonicalPath.startsWith('/') + ? canonicalPath + : `/${canonicalPath}`; + const url = origin + pathClean; - const origin = process.env.NEXT_PUBLIC_SELF_HOST || ''; - const url = origin + (canonicalPath?.startsWith('/') ? canonicalPath : `/${canonicalPath}`); + // optional: pastiin OG image absolute URL + const ogImageUrl = product?.image?.startsWith('http') + ? product.image + : origin + product?.image; return ( <BasicLayout> <Seo title={`${product.name} - Indoteknik.com`} description='Temukan pilihan produk B2B Industri & Alat Teknik untuk Perusahaan, UMKM & Pemerintah dengan lengkap, mudah dan transparan.' + canonical={url} // <- ini diprioritaskan sama komponen Seo openGraph={{ url, - images: [{ url: product?.image, width: 800, height: 800, alt: product?.name }], + images: [ + { + url: ogImageUrl, + width: 800, + height: 800, + alt: product?.name, + }, + ], type: 'product', }} - additionalMetaTags={[{ name: 'keywords', content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}` }]} - canonical={url} + additionalMetaTags={[ + { + name: 'keywords', + content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}`, + }, + ]} /> <div className='md:container pt-4 md:pt-6'> @@ -75,5 +122,4 @@ const ProductDetailPage: NextPage<PageProps & { canonicalPath: string }> = ({ pr ); }; - -export default ProductDetailPage
\ No newline at end of file +export default ProductDetailPage; |
