- {imageUrl(product.seller.imageId) ? (
+ {imageUrl(product.seller.imageId, product.seller.image) ? (
// eslint-disable-next-line @next/next/no-img-element
diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx
index 4b0d16e..cc658b1 100644
--- a/src/app/admin/products/page.tsx
+++ b/src/app/admin/products/page.tsx
@@ -3,6 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
+import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
interface ProductRow {
id: string;
@@ -308,15 +309,29 @@ function AdminProductsPageInner() {
return (
-
-
- {product.name}
-
- ID: {product.id.slice(0, 8)}
+
+
+ {product.image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ })
+ ) : (
+ N/A
+ )}
+
+
+
+ {product.name}
+
+ ID: {product.id.slice(0, 8)}
+
|
diff --git a/src/app/admin/review/[productId]/page.tsx b/src/app/admin/review/[productId]/page.tsx
index 2236bf2..b4e8c08 100644
--- a/src/app/admin/review/[productId]/page.tsx
+++ b/src/app/admin/review/[productId]/page.tsx
@@ -1,24 +1,17 @@
"use client";
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
+import { resolveBackendImageUrl } from "@/lib/image-url";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
-const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
-
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
-function imgUrl(id: string | null | undefined) {
- if (!id) return null;
- if (id.startsWith("http")) return id;
- return `${API_BASE}/api/v1.0/file/image/${id}`;
-}
-
-function isNonEmptyString(value: string | undefined): value is string {
- return typeof value === "string" && value.length > 0;
+function imgUrl(id?: string | null, image?: string | null) {
+ return resolveBackendImageUrl({ image, imageId: id }) || null;
}
function formatMoney(value?: string | number | null, currency?: string | null) {
@@ -83,6 +76,7 @@ interface ReviewModel extends ReviewMeasurement {
name?: string;
sku?: string;
imageId?: string;
+ image?: string;
warehouses?: ReviewWarehouse[];
productMeasurements?: ReviewMeasurement[];
}
@@ -92,6 +86,12 @@ interface ReviewInfoItem {
paramValue: string;
}
+interface ReviewFileItem {
+ id?: string;
+ fileId?: string;
+ file?: string;
+}
+
interface ReviewProductData {
name?: string;
description?: string;
@@ -102,13 +102,15 @@ interface ReviewProductData {
isEligibleToExport?: boolean;
isPreOrder?: boolean;
preOrderDay?: string | number;
- productImages?: Array<{ sequence?: number; imageId?: string }>;
+ productImages?: Array<{ sequence?: number; imageId?: string; image?: string }>;
+ productFiles?: ReviewFileItem[];
productModels?: ReviewModel[];
productKeyWords?: string[];
productFeatures?: string[];
productInformations?: ReviewInfoItem[];
categoryInformations?: ReviewInfoItem[];
subCategory?: {
+ id?: string;
name?: string;
category?: {
name?: string;
@@ -128,6 +130,7 @@ interface ReviewProductData {
id?: string;
name?: string;
imageId?: string;
+ image?: string;
};
}
@@ -138,6 +141,17 @@ interface CompareRow {
isUpdate?: boolean;
}
+type CompareSectionKey =
+ | "images"
+ | "details"
+ | "features"
+ | "keywords"
+ | "info"
+ | "files"
+ | "models"
+ | "compliance"
+ | "warranty";
+
function hasChangesForPaths(rows: CompareRow[], paths: string[]) {
return rows.some((row) => {
if (!row?.field || row.isUpdate !== true) return false;
@@ -172,9 +186,9 @@ function ModelCard({
return (
- {imgUrl(model.imageId) && (
+ {imgUrl(model.imageId, model.image) && (
// eslint-disable-next-line @next/next/no-img-element
- !})
+ 
)}
{model.name || `Model ${index + 1}`}
@@ -334,9 +348,14 @@ function SectionCard({
}
function extractIdChange(payload: unknown) {
- const rows = Array.isArray((payload as { data?: unknown })?.data)
- ? ((payload as { data?: unknown[] }).data ?? [])
- : [];
+ const data = (payload as { data?: unknown })?.data;
+ const rows = Array.isArray(data)
+ ? data
+ : data && typeof data === "object" && Array.isArray((data as { rows?: unknown })?.rows)
+ ? (data as { rows: unknown[] }).rows
+ : Array.isArray((payload as { rows?: unknown })?.rows)
+ ? ((payload as { rows: unknown[] }).rows ?? [])
+ : [];
return rows.find(
(row) =>
@@ -354,6 +373,149 @@ function extractIdChange(payload: unknown) {
| undefined;
}
+function getCompareRows(payload: unknown): CompareRow[] {
+ const data = (payload as { data?: unknown })?.data;
+ if (Array.isArray(data)) return data as CompareRow[];
+ if (data && typeof data === "object" && Array.isArray((data as { rows?: unknown })?.rows)) {
+ return (data as { rows: CompareRow[] }).rows;
+ }
+ if (Array.isArray((payload as { rows?: unknown })?.rows)) {
+ return (payload as { rows: CompareRow[] }).rows;
+ }
+ return [];
+}
+
+function parseFieldSegments(field: string) {
+ return field.split(".").map((part) => {
+ const match = part.match(/^([^\[]+)(?:\[(.+)\])?$/);
+ if (!match) return { name: part };
+ const selector = match[2];
+ if (!selector) return { name: match[1] };
+ const selectorMatch = selector.match(/^([^=]+)=(.*)$/);
+ if (selectorMatch) {
+ return {
+ name: match[1],
+ selectorKey: selectorMatch[1],
+ selectorValue: selectorMatch[2],
+ };
+ }
+ return {
+ name: match[1],
+ index: Number(selector),
+ };
+ });
+}
+
+function setCompareValue(target: Record , field: string, value: unknown) {
+ const segments = parseFieldSegments(field);
+ let cursor: Record | unknown[] = target;
+
+ segments.forEach((segment, index) => {
+ const isLast = index === segments.length - 1;
+ const current = cursor as Record;
+
+ if (segment.selectorKey) {
+ if (!Array.isArray(current[segment.name])) current[segment.name] = [];
+ const list = current[segment.name] as Array>;
+ const compareKey = `${segment.selectorKey}:${segment.selectorValue}`;
+ let item = list.find(
+ (entry) =>
+ entry.__compareKey === compareKey ||
+ String(entry[segment.selectorKey || ""]) === segment.selectorValue
+ );
+ if (!item) {
+ item = { __compareKey: compareKey, [segment.selectorKey]: segment.selectorValue };
+ list.push(item);
+ }
+ if (isLast) {
+ const explicitValue = value === null ? segment.selectorValue : value;
+ item[segment.selectorKey] = explicitValue;
+ } else {
+ cursor = item;
+ }
+ return;
+ }
+
+ if (typeof segment.index === "number" && Number.isFinite(segment.index)) {
+ if (!Array.isArray(current[segment.name])) current[segment.name] = [];
+ const list = current[segment.name] as unknown[];
+ if (isLast) {
+ list[segment.index] = value;
+ } else {
+ if (!list[segment.index] || typeof list[segment.index] !== "object") {
+ list[segment.index] = {};
+ }
+ cursor = list[segment.index] as Record;
+ }
+ return;
+ }
+
+ if (isLast) {
+ current[segment.name] = value;
+ return;
+ }
+
+ if (!current[segment.name] || typeof current[segment.name] !== "object") {
+ current[segment.name] = {};
+ }
+ cursor = current[segment.name] as Record;
+ });
+}
+
+function compactCompareProduct(product: Record): ReviewProductData {
+ for (const key of ["productImages", "productFiles", "productInformations", "categoryInformations", "productModels"]) {
+ const value = product[key];
+ if (!Array.isArray(value)) continue;
+ product[key] = value.filter((item) => {
+ if (item === null || item === undefined) return false;
+ if (typeof item !== "object") return item !== "";
+ return Object.entries(item as Record).some(
+ ([entryKey, entryValue]) =>
+ entryKey !== "__compareKey" &&
+ entryValue !== null &&
+ entryValue !== undefined &&
+ entryValue !== ""
+ );
+ });
+ product[key] = (product[key] as Record[]).map((item) => {
+ if (!item || typeof item !== "object") return item;
+ const rest = { ...item };
+ delete rest.__compareKey;
+ return rest;
+ });
+ }
+
+ if (Array.isArray(product.productFeatures)) {
+ product.productFeatures = product.productFeatures.filter((value) => value !== null && value !== undefined && value !== "");
+ }
+
+ if (Array.isArray(product.productImages)) {
+ product.productImages = product.productImages.sort(
+ (a, b) =>
+ (Number((a as { sequence?: unknown }).sequence) || 0) -
+ (Number((b as { sequence?: unknown }).sequence) || 0)
+ );
+ }
+
+ return product as ReviewProductData;
+}
+
+function buildProductsFromCompareRows(rows: CompareRow[]) {
+ const oldProduct: Record = {};
+ const newProduct: Record = {};
+
+ rows.forEach((row) => {
+ if (!row.field) return;
+ setCompareValue(oldProduct, row.field, row.oldValue);
+ setCompareValue(newProduct, row.field, row.newValue);
+ });
+
+ return {
+ oldProduct: compactCompareProduct(oldProduct),
+ newProduct: compactCompareProduct(newProduct),
+ };
+}
+
function isProductUpdateFromCompare(
change:
| {
@@ -379,11 +541,17 @@ function ProductColumn({
label,
accent,
compareRows = [],
+ section,
+ sectionTitle,
+ showLabel = true,
}: {
product: ReviewProductData | null;
label: string;
accent?: boolean;
compareRows?: CompareRow[];
+ section?: CompareSectionKey;
+ sectionTitle?: string;
+ showLabel?: boolean;
}) {
if (!product) return (
Memuat data...
@@ -399,32 +567,46 @@ function ProductColumn({
const categoryInfos = Array.isArray(product.categoryInformations)
? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
: [];
- const allImages: string[] = [
- ...(product.imageId ? [product.imageId] : []),
+ const productFiles = Array.isArray(product.productFiles)
+ ? product.productFiles.filter((item) => item.file || item.fileId || item.id)
+ : [];
+ const allImages = [
+ ...(product.imageId || product.image
+ ? [{ id: product.imageId, url: imgUrl(product.imageId, product.image) }]
+ : []),
...images
.sort(
(a: { sequence?: number }, b: { sequence?: number }) =>
(a.sequence ?? 0) - (b.sequence ?? 0)
)
- .map((img: { imageId?: string }) => img.imageId)
- .filter(isNonEmptyString),
+ .map((img: { imageId?: string; image?: string }) => ({
+ id: img.imageId,
+ url: imgUrl(img.imageId, img.image),
+ }))
+ .filter((img) => Boolean(img.url)),
];
+ const shouldRender = (key: CompareSectionKey) => !section || section === key;
+
return (
-
- {label}
-
+ {showLabel ? (
+
+ {label}
+
+ ) : null}
- {allImages.length > 0 && (
-
-
- {allImages.map((imageId: string, i: number) => {
- const url = imgUrl(imageId);
+ {shouldRender("images") && allImages.length > 0 && (
+
+
+ {allImages.map((image, i: number) => {
+ const url = image.url;
if (!url) return null;
return (
- // eslint-disable-next-line @next/next/no-img-element
- 
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+ 
+
);
})}
@@ -432,8 +614,9 @@ function ProductColumn({
)}
{/* Basic Info */}
-
@@ -454,12 +636,12 @@ function ProductColumn({
{product.isPreOrder && }
-
-
+
+ ) : null}
{/* Features */}
- {features.length > 0 && (
-
+ {shouldRender("features") && features.length > 0 && (
+
{features.map((f: string, i: number) => (
{f}
@@ -469,8 +651,8 @@ function ProductColumn({
)}
{/* Keywords */}
- {keywords.length > 0 && (
-
+ {shouldRender("keywords") && keywords.length > 0 && (
+
{keywords.map((k: string) => (
{k}
@@ -479,9 +661,49 @@ function ProductColumn({
)}
+ {shouldRender("info") && (productInfos.length > 0 || categoryInfos.length > 0) && (
+
+ {productInfos.length > 0 ? (
+
+ Informasi Produk
+ {productInfos.map((item, index) => (
+ |
+ ))}
+
+ ) : null}
+ {categoryInfos.length > 0 ? (
+ 0 ? "mt-5 space-y-1" : "space-y-1"}>
+ Informasi Kategori
+ {categoryInfos.map((item, index) => (
+ |
+ ))}
+
+ ) : null}
+
+ )}
+
+ {shouldRender("files") && productFiles.length > 0 && (
+
+
+ {productFiles.map((file, index) => {
+ const label = file.fileId || file.file || file.id || `Dokumen ${index + 1}`;
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+ )}
+
{/* Models */}
- {models.length > 0 && (
-
+ {shouldRender("models") && models.length > 0 && (
+
@@ -492,8 +714,8 @@ function ProductColumn({
)}
{/* Compliance */}
- {product.complianceInformation && (
-
+ {shouldRender("compliance") && product.complianceInformation && (
+
@@ -501,8 +723,8 @@ function ProductColumn({
)}
{/* Warranty */}
- {product.warrantyInformation && (
-
+ {shouldRender("warranty") && product.warrantyInformation && (
+
@@ -511,6 +733,88 @@ function ProductColumn({
);
}
+function CompareSectionPair({
+ section,
+ title,
+ product,
+ oldProduct,
+ compareRows,
+}: {
+ section: CompareSectionKey;
+ title: string;
+ product: ReviewProductData;
+ oldProduct: ReviewProductData | null;
+ compareRows: CompareRow[];
+}) {
+ function hasSectionContent(item: ReviewProductData | null, sectionKey: CompareSectionKey) {
+ if (!item) return false;
+ switch (sectionKey) {
+ case "images":
+ return Boolean(item.image || item.imageId || (Array.isArray(item.productImages) && item.productImages.length > 0));
+ case "details":
+ return Boolean(
+ item.name ||
+ item.description ||
+ item.subCategory?.category?.name ||
+ item.subCategory?.name ||
+ item.subCategory?.id ||
+ item.isNew !== undefined ||
+ item.isEligibleToExport !== undefined ||
+ item.isPreOrder !== undefined ||
+ item.preOrderDay !== undefined
+ );
+ case "features":
+ return Array.isArray(item.productFeatures) && item.productFeatures.filter(Boolean).length > 0;
+ case "keywords":
+ return Array.isArray(item.productKeyWords) && item.productKeyWords.filter(Boolean).length > 0;
+ case "info":
+ return (
+ (Array.isArray(item.productInformations) && item.productInformations.some((entry) => entry.paramName && entry.paramValue)) ||
+ (Array.isArray(item.categoryInformations) && item.categoryInformations.some((entry) => entry.paramName && entry.paramValue))
+ );
+ case "files":
+ return Array.isArray(item.productFiles) && item.productFiles.some((entry) => entry.file || entry.fileId || entry.id);
+ case "models":
+ return Array.isArray(item.productModels) && item.productModels.length > 0;
+ case "compliance":
+ return Boolean(
+ item.complianceInformation?.countryOfOrigin ||
+ item.complianceInformation?.safetyWarning ||
+ item.complianceInformation?.isDangerousGoodRegulation !== undefined
+ );
+ case "warranty":
+ return Boolean(item.warrantyInformation?.type || item.warrantyInformation?.duration || item.warrantyInformation?.durationType);
+ default:
+ return false;
+ }
+ }
+
+ if (!hasSectionContent(product, section) && !hasSectionContent(oldProduct, section)) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
// ─── Main Page ─────────────────────────────────────────────────────────────
function AdminReviewDetailPageInner() {
@@ -524,6 +828,7 @@ function AdminReviewDetailPageInner() {
const [oldProduct, setOldProduct] = useState(null); // original (compare)
const [isComparison, setIsComparison] = useState(false);
const [isUpdateProduct, setIsUpdateProduct] = useState(false);
+ const [reviewActionId, setReviewActionId] = useState("");
const [compareRows, setCompareRows] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
@@ -542,55 +847,43 @@ function AdminReviewDetailPageInner() {
setOldProduct(null);
setIsComparison(false);
setIsUpdateProduct(false);
+ setReviewActionId(params.productId);
setCompareRows([]);
- const reviewFetch = fetch(`/api/admin/review/${params.productId}`, {
- headers: { "x-auth-token": getToken() },
- }).then((r) => r.json());
-
const compareFetch = fetch(`/api/admin/review/${params.productId}/compare`, {
headers: { "x-auth-token": getToken() },
})
.then((r) => r.json())
.catch(() => null);
- Promise.all([reviewFetch, compareFetch])
- .then(async ([reviewData, compareData]) => {
- if (!reviewData?.data) throw new Error(reviewData?.responseDesc || "Data tidak ditemukan");
+ compareFetch
+ .then(async (compareData) => {
+ const rows = getCompareRows(compareData);
const idChange = extractIdChange(compareData);
- const reviewId = idChange?.newValue || params.productId;
-
- let updated = reviewData.data;
- if (reviewId && reviewId !== params.productId) {
- const reviewRes = await fetch(`/api/admin/review/${reviewId}`, {
- headers: { "x-auth-token": getToken() },
- });
- const reviewOverride = await reviewRes.json().catch(() => ({}));
- if (!reviewRes.ok || !reviewOverride?.data) {
- throw new Error(reviewOverride?.responseDesc || "Gagal memuat versi produk review");
- }
- updated = reviewOverride.data;
- }
-
- setProduct(updated);
- setCompareRows(Array.isArray(compareData?.data) ? compareData.data : []);
-
const isUpdate = isProductUpdateFromCompare(idChange);
- setIsUpdateProduct(isUpdate);
- const shouldCompare = isUpdate && Boolean(idChange?.oldValue) && Boolean(idChange?.newValue);
- setIsComparison(shouldCompare);
-
- if (shouldCompare) {
- const originalRes = await fetch(`/api/admin/review/${reviewId}/original`, {
- headers: { "x-auth-token": getToken() },
- });
- const originalData = await originalRes.json().catch(() => ({}));
- if (!originalRes.ok || !originalData?.data) {
- throw new Error(originalData?.responseDesc || "Gagal memuat versi produk saat ini");
- }
- setOldProduct(originalData.data);
+ if (isUpdate && rows.length > 0) {
+ const built = buildProductsFromCompareRows(rows);
+ setProduct(built.newProduct);
+ setOldProduct(built.oldProduct);
+ setReviewActionId(idChange?.newValue || params.productId);
+ setCompareRows(rows);
+ setIsUpdateProduct(true);
+ setIsComparison(true);
+ return;
}
+
+ const reviewRes = await fetch(`/api/admin/review/${params.productId}`, {
+ headers: { "x-auth-token": getToken() },
+ });
+ const reviewData = await reviewRes.json().catch(() => ({}));
+ if (!reviewData?.data) throw new Error(reviewData?.responseDesc || "Data tidak ditemukan");
+
+ setProduct(reviewData.data);
+ setReviewActionId(params.productId);
+ setCompareRows(rows);
+ setIsUpdateProduct(isUpdate);
+ setIsComparison(false);
})
.catch((e) => setLoadError(e.message || "Gagal memuat data"))
.finally(() => setLoading(false));
@@ -608,7 +901,7 @@ function AdminReviewDetailPageInner() {
setActing(true);
setActionError("");
try {
- const res = await fetch(`/api/admin/review/${params.productId}`, {
+ const res = await fetch(`/api/admin/review/${reviewActionId || params.productId}`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-auth-token": getToken() },
body: JSON.stringify({ action, isUpdate: isUpdateProduct, reason }),
@@ -658,12 +951,14 @@ function AdminReviewDetailPageInner() {
? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
: [];
const allImages = [
- ...(product.imageId ? [product.imageId] : []),
+ ...(product.imageId || product.image
+ ? [{ id: product.imageId, url: imgUrl(product.imageId, product.image) }]
+ : []),
...(Array.isArray(product.productImages)
? product.productImages
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
- .map((item) => item.imageId)
- .filter((value): value is string => Boolean(value))
+ .map((item) => ({ id: item.imageId || "", url: imgUrl(item.imageId, item.image) || "" }))
+ .filter((item) => Boolean(item.url))
: []),
];
@@ -805,23 +1100,16 @@ function AdminReviewDetailPageInner() {
) : null}
{isComparison ? (
-
-
-
+
+
+
+
+
+
+
+
+
+
) : (
<>
@@ -903,13 +1191,13 @@ function AdminReviewDetailPageInner() {
{allImages.length ? (
- {allImages.map((imageId, index) => {
- const src = imgUrl(imageId);
+ {allImages.map((image, index) => {
+ const src = image.url;
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
- {imgUrl(product.seller.imageId) ? (
+ {imgUrl(product.seller.imageId, product.seller.image) ? (
// eslint-disable-next-line @next/next/no-img-element
)
@@ -1028,9 +1316,9 @@ function AdminReviewDetailPageInner() {
- {imgUrl(product.seller.imageId) ? (
+ {imgUrl(product.seller.imageId, product.seller.image) ? (
// eslint-disable-next-line @next/next/no-img-element
- !})
+ 
) : (
storefront
diff --git a/src/app/admin/review/page.tsx b/src/app/admin/review/page.tsx
index b8ee9dd..80c73be 100644
--- a/src/app/admin/review/page.tsx
+++ b/src/app/admin/review/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
+import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
interface ReviewRow {
id: string;
@@ -14,8 +15,6 @@ interface ReviewRow {
rejectReason: string | null;
}
-const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
-
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
@@ -170,7 +169,7 @@ export default function AdminReviewPage() {
{item.image ? (
/* eslint-disable-next-line @next/next/no-img-element */
)
diff --git a/src/app/api/admin/products/route.ts b/src/app/api/admin/products/route.ts
index 3147727..ab6c835 100644
--- a/src/app/api/admin/products/route.ts
+++ b/src/app/api/admin/products/route.ts
@@ -11,12 +11,29 @@ export async function GET(req: NextRequest) {
const endpoint =
tab === "deleted"
? "/api/v1.0/admin/deleted/product"
- : "/api/v1.0/product";
+ : "/api/v1.0/admin/product";
const res = await fetch(`${API_URL}${endpoint}?page=${page}&size=${size}`, {
headers: makeHeaders(token),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
+
+ if (tab !== "deleted" && !res.ok) {
+ console.warn("[api/admin/products] admin product endpoint failed, falling back", {
+ endpoint,
+ status: res.status,
+ responseCode: data?.responseCode,
+ responseDesc: data?.responseDesc,
+ });
+
+ const fallbackRes = await fetch(`${API_URL}/api/v1.0/product?page=${page}&size=${size}`, {
+ headers: makeHeaders(token),
+ cache: "no-store",
+ });
+ const fallbackData = await fallbackRes.json().catch(() => ({}));
+ return NextResponse.json(fallbackData, { status: fallbackRes.status });
+ }
+
return NextResponse.json(data, { status: res.status });
}
diff --git a/src/app/api/admin/review/[productId]/original/route.ts b/src/app/api/admin/review/[productId]/original/route.ts
index 6f4058e..612559c 100644
--- a/src/app/api/admin/review/[productId]/original/route.ts
+++ b/src/app/api/admin/review/[productId]/original/route.ts
@@ -3,16 +3,28 @@ import { API_URL, makeHeaders } from "@/lib/api";
function extractOriginalProductId(payload: unknown) {
const data = (payload as { data?: unknown })?.data;
+ const rows =
+ Array.isArray((payload as { rows?: unknown })?.rows)
+ ? (payload as { rows: unknown[] }).rows
+ : data && typeof data === "object" && !Array.isArray(data) && Array.isArray((data as { rows?: unknown })?.rows)
+ ? (data as { rows: unknown[] }).rows
+ : Array.isArray(data)
+ ? data
+ : Array.isArray(payload)
+ ? payload
+ : [];
if (data && typeof data === "object" && !Array.isArray(data)) {
const directId =
(data as { original?: { id?: unknown } })?.original?.id ||
(data as { currentProduct?: { id?: unknown } })?.currentProduct?.id ||
- (data as { oldProduct?: { id?: unknown } })?.oldProduct?.id;
+ (data as { oldProduct?: { id?: unknown } })?.oldProduct?.id ||
+ (data as { originalProductId?: unknown })?.originalProductId ||
+ (data as { oldProductId?: unknown })?.oldProductId ||
+ (data as { currentProductId?: unknown })?.currentProductId;
if (typeof directId === "string" && directId) return directId;
}
- const rows = Array.isArray(data) ? data : Array.isArray(payload) ? payload : [];
const idRow = rows.find(
(row) =>
row &&
diff --git a/src/app/api/dashboard/seller/route.ts b/src/app/api/dashboard/seller/route.ts
index d1c6855..69f412e 100644
--- a/src/app/api/dashboard/seller/route.ts
+++ b/src/app/api/dashboard/seller/route.ts
@@ -27,8 +27,8 @@ function pickText(value: unknown): string {
function extractScalar(payload: unknown) {
const candidates = [
- payload,
(payload as { data?: unknown })?.data,
+ payload,
(payload as { rows?: unknown[] })?.rows?.[0],
];
@@ -36,8 +36,9 @@ function extractScalar(payload: unknown) {
if (typeof item === "number" || typeof item === "string") return item;
if (item && typeof item === "object") {
const record = item as Record ;
- for (const key of ["total", "count", "value", "data", "totalProduct", "totalItem"]) {
- if (record[key] != null) return record[key];
+ for (const key of ["total", "count", "value", "totalProduct", "totalItem"]) {
+ const value = record[key];
+ if (typeof value === "number" || typeof value === "string") return value;
}
}
}
diff --git a/src/app/api/file/image/[...path]/route.ts b/src/app/api/file/image/[...path]/route.ts
new file mode 100644
index 0000000..b0004a2
--- /dev/null
+++ b/src/app/api/file/image/[...path]/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { API_URL, makeHeaders } from "@/lib/api";
+
+function normalizeBearerToken(rawToken: string) {
+ if (!rawToken) return "";
+ return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
+}
+
+export async function GET(
+ req: NextRequest,
+ context: { params: Promise<{ path: string[] }> }
+) {
+ const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
+ const { path } = await context.params;
+ const imagePath = path.map(encodeURIComponent).join("/");
+
+ const headers = makeHeaders(token);
+ delete headers["Content-Type"];
+
+ const res = await fetch(`${API_URL}/api/v1.0/file/image/${imagePath}`, {
+ headers,
+ cache: "no-store",
+ });
+
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
+ const body = await res.arrayBuffer();
+
+ return new NextResponse(body, {
+ status: res.status,
+ headers: {
+ "Content-Type": contentType,
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/src/app/api/products/create/route.ts b/src/app/api/products/create/route.ts
index 520de81..2652568 100644
--- a/src/app/api/products/create/route.ts
+++ b/src/app/api/products/create/route.ts
@@ -1,16 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
+import { writeFile } from "node:fs/promises";
+import path from "node:path";
import { API_URL, makeHeaders } from "@/lib/api";
export async function POST(req: NextRequest) {
const token = req.headers.get("x-auth-token") || "";
const body = await req.json();
+ const endpoint = `${API_URL}/api/v1.0/product`;
- const res = await fetch(`${API_URL}/api/v1.0/product`, {
+ const res = await fetch(endpoint, {
method: "POST",
headers: makeHeaders(token),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
+ if (process.env.DEBUG_BACKEND_PROXY === "true") {
+ const capture = {
+ capturedAt: new Date().toISOString(),
+ localEndpoint: "/api/products/create",
+ backendRequest: {
+ endpoint,
+ method: "POST",
+ headers: {
+ ...makeHeaders(token),
+ Authorization: token ? "Bearer [redacted]" : "",
+ },
+ body,
+ },
+ backendResponse: {
+ httpStatus: res.status,
+ ok: res.ok,
+ body: data,
+ },
+ };
+
+ await writeFile(
+ path.join(process.cwd(), "product-create-submit-log.json"),
+ JSON.stringify(capture, null, 2)
+ ).catch((error) => {
+ console.warn("[api/products/create] failed to write capture log", {
+ message: error instanceof Error ? error.message : String(error),
+ });
+ });
+ }
+
return NextResponse.json(data, { status: res.status });
}
diff --git a/src/components/product-variant-showcase.tsx b/src/components/product-variant-showcase.tsx
index c076394..1bfc13c 100644
--- a/src/components/product-variant-showcase.tsx
+++ b/src/components/product-variant-showcase.tsx
@@ -1,30 +1,25 @@
"use client";
-import { useEffect, useState } from "react";
+import { useState } from "react";
+import { resolveBackendImageUrl } from "@/lib/image-url";
import {
buildMeasurementLabel,
formatDimension,
formatMoney,
formatWeight,
- getAllProductImageIds,
+ getAllProductImageRefs,
getEffectiveDimensionLabel,
getEffectivePriceLabel,
getEffectiveWeightLabel,
getModelMeasurements,
getModelPriceLabel,
modelHasMeasurements,
- type VariantMeasurementLike,
- type VariantModelLike,
type VariantProductLike,
type VariantWarehouseLike,
} from "@/lib/product-variants";
-const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
-
-function imgUrl(id?: string | null) {
- if (!id) return null;
- if (id.startsWith("http")) return id;
- return `${API_BASE}/api/v1.0/file/image/${id}`;
+function imgUrl(id?: string | null, image?: string | null) {
+ return resolveBackendImageUrl({ image, imageId: id }) || null;
}
function DetailRow({ label, value }: { label: string; value?: string | number | null }) {
@@ -98,24 +93,19 @@ export function ProductVariantShowcase({
}) {
const l = { ...defaultLabels, ...labels };
const models = Array.isArray(product.productModels) ? product.productModels : [];
- const productImages = getAllProductImageIds(product);
+ const productImages = getAllProductImageRefs(product);
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
const [selectedMeasurementIndex, setSelectedMeasurementIndex] = useState(0);
- useEffect(() => {
- setSelectedModelIndex(0);
- setSelectedMeasurementIndex(0);
- }, [product]);
-
const selectedModel = models[selectedModelIndex] || null;
const measurements = selectedModel ? getModelMeasurements(selectedModel) : [];
const hasMeasurements = selectedModel ? modelHasMeasurements(selectedModel) : false;
const selectedMeasurement = hasMeasurements ? measurements[selectedMeasurementIndex] || measurements[0] || null : null;
const selectedImageId =
(selectedModel?.imageId as string | undefined) ||
- productImages[0] ||
+ productImages[0]?.id ||
null;
- const selectedImageUrl = imgUrl(selectedImageId);
+ const selectedImageUrl = imgUrl(selectedImageId, selectedModel?.image || productImages[0]?.url);
const selectedPrice = selectedMeasurement
? formatMoney(selectedMeasurement.price, selectedMeasurement.currency || selectedModel?.currency)
@@ -157,10 +147,10 @@ export function ProductVariantShowcase({
return (
-
-
+
+
{l.summaryTitle}
-
+
{l.productPrice}
{getEffectivePriceLabel(models) || "-"}
@@ -183,10 +173,10 @@ export function ProductVariantShowcase({
-
-
+
+
-
+
{selectedImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element

@@ -196,11 +186,11 @@ export function ProductVariantShowcase({
{productImages.length > 1 ? (
- {productImages.slice(0, 4).map((imageId, index) => {
- const image = imgUrl(imageId);
+ {productImages.slice(0, 4).map((imageRef, index) => {
+ const image = imgUrl(imageRef.id, imageRef.url);
if (!image) return null;
return (
-
+
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -213,11 +203,11 @@ export function ProductVariantShowcase({
{l.modelLabel}
-
+
{models.map((model, index) => {
const active = index === selectedModelIndex;
const measurementCount = getModelMeasurements(model).length;
- const thumb = imgUrl(model.imageId || selectedImageId);
+ const thumb = imgUrl(model.imageId || selectedImageId, model.image || selectedImageUrl);
return (
-
+
{model.name || `Model ${index + 1}`}
{getModelPriceLabel(model)}
@@ -282,18 +272,20 @@ export function ProductVariantShowcase({
)}
-
- {l.selectedDetailTitle}
-
-
- {selectedMeasurement ? (
-
- ) : null}
-
-
-
-
-
+
+ {l.selectedDetailTitle}
+
+
+
+ {selectedMeasurement ? (
+
+ ) : null}
+
+
+
+
+
+
{selectedWarehouses.length > 0 ? (
diff --git a/src/lib/image-url.ts b/src/lib/image-url.ts
new file mode 100644
index 0000000..f13e828
--- /dev/null
+++ b/src/lib/image-url.ts
@@ -0,0 +1,25 @@
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+export function resolveBackendImageUrl({
+ image,
+ imageId,
+ tmp = true,
+}: {
+ image?: string | null;
+ imageId?: string | null;
+ tmp?: boolean;
+}) {
+ if (isNonEmptyString(image)) return image;
+ if (!isNonEmptyString(imageId)) return "";
+ if (imageId.startsWith("http")) return imageId;
+ const segment = tmp ? "image/tmp" : "image";
+ return `${API_BASE}/api/v1.0/file/${segment}/${imageId}`;
+}
+
+export function resolveBackendImageUrlFromValue(value?: string | null, tmp = true) {
+ return resolveBackendImageUrl({ imageId: value, tmp });
+}
diff --git a/src/lib/product-variants.ts b/src/lib/product-variants.ts
index 4b8d7b8..dd29e2d 100644
--- a/src/lib/product-variants.ts
+++ b/src/lib/product-variants.ts
@@ -32,13 +32,21 @@ export interface VariantModelLike extends VariantMeasurementLike {
name?: string | null;
sku?: string | null;
imageId?: string | null;
+ image?: string | null;
warehouses?: VariantWarehouseLike[] | null;
productMeasurements?: VariantMeasurementLike[] | null;
}
+export interface VariantImageLike {
+ sequence?: number | null;
+ imageId?: string | null;
+ image?: string | null;
+}
+
export interface VariantProductLike {
imageId?: string | null;
- productImages?: Array<{ sequence?: number | null; imageId?: string | null }> | null;
+ image?: string | null;
+ productImages?: VariantImageLike[] | null;
productModels?: VariantModelLike[] | null;
}
@@ -116,6 +124,24 @@ export function getAllProductImageIds(product: VariantProductLike) {
];
}
+export function getSortedProductImageRefs(product: VariantProductLike) {
+ const gallery = Array.isArray(product.productImages) ? product.productImages : [];
+ return gallery
+ .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
+ .map((item) => ({
+ id: item.imageId || null,
+ url: item.image || null,
+ }))
+ .filter((item) => Boolean(item.id || item.url));
+}
+
+export function getAllProductImageRefs(product: VariantProductLike) {
+ return [
+ ...(product.imageId || product.image ? [{ id: product.imageId || null, url: product.image || null }] : []),
+ ...getSortedProductImageRefs(product),
+ ];
+}
+
export function getModelMeasurements(model: VariantModelLike) {
return Array.isArray(model.productMeasurements)
? model.productMeasurements
|