summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiqdad <ahmadmiqdad27@gmail.com>2025-10-28 12:26:03 +0700
committerMiqdad <ahmadmiqdad27@gmail.com>2025-10-28 12:26:03 +0700
commit7f71d52e2e6e6e8ffb5ea2837be84c800d04ef95 (patch)
treeb7f687b5f3034adff037315513f2f216fc87328d
parentb3367a2d1882e0da9a366e1dfe07e9c9851989e3 (diff)
<Miqdad> canonical product detail
-rw-r--r--src-migrate/components/seo.tsx87
-rw-r--r--src-migrate/libs/slug.ts35
-rw-r--r--src-migrate/pages/shop/product/[slug].tsx120
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 &amp; Alat Teknik untuk Perusahaan, UMKM &amp; 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;