diff --git a/HANDOFF.md b/HANDOFF.md index d81d0f9..e4d75b8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -14,6 +14,160 @@ The latest build was verified successfully with: npm run build ``` +## Current Local Changes After `7e6446b` + +These are the important local changes made after the last recorded commit. The latest local build was verified successfully with `npm run build`, and the production local server was restarted on `http://localhost:3000`. + +### Admin review compare flow + +File: +- `src/app/admin/review/[productId]/page.tsx` + +Behavior: +- Product update review now uses the compare API as the source of truth: + - frontend proxy: `GET /api/admin/review/{productId}/compare` + - backend: `GET /api/v1.0/product/compare/{productId}` +- The compare payload is parsed from rows like: + - `productImages[id=...].image` + - `productFiles[id=...].fileId` + - `productModels[sku=...].price` + - `categoryInformations[paramName=Design].paramValue` + - `productFeatures[3]` + - nested objects such as `complianceInformation.safetyWarning` +- The page reconstructs: + - `oldProduct` from `oldValue` + - `newProduct` from `newValue` +- The compare layout is now section-paired, not product-paired: + - `Gambar Produk (Diajukan)` + - `Gambar Produk (Live Saat Ini)` + - `Detail Produk (Diajukan)` + - `Detail Produk (Live Saat Ini)` + - and so on for features, keywords, info, files, models, compliance, warranty +- Empty section pairs are hidden. If both proposed and live sections have no displayable content, nothing is rendered. +- `Status` is not shown in the compare `Detail Produk` section and no longer marks the section as updated. +- Compare image thumbnails are rendered as small square thumbnails with `object-contain`, so images are visible in full instead of cropped into wide banners. +- Approve/reject for update reviews uses the resolved review/action id from compare, when available. + +Reference test data: +- `/Users/wirabasalamah/Downloads/json compare.txt` +- Example compare API: + - `/api/v1.0/product/compare/ba70a8e3-2342-4cd3-a7c7-a0f1b58689ab` + +### Image URL handling + +Files: +- `src/lib/image-url.ts` +- `src/components/product-variant-showcase.tsx` +- `src/app/(dashboard)/products/[productId]/detail/page.tsx` +- `src/app/admin/products/[productId]/page.tsx` +- `src/app/admin/review/[productId]/page.tsx` +- `src/app/admin/review/page.tsx` +- `src/app/admin/news/new/page.tsx` +- `src/app/admin/news/[newsId]/edit/page.tsx` +- `src/app/admin/places/PlaceForm.tsx` + +Behavior: +- Added shared image URL helper: + - if backend already provides an image URL, use it as-is + - otherwise build a fallback URL from `imageId` + - default fallback uses `/api/v1.0/file/image/tmp/{imageId}` +- Seller/admin product details and admin review now use backend `image` fields before building URLs. +- Product variant showcase now supports `image` on product images and model images. + +### Product create/edit thumbnail persistence + +Files: +- `src/app/(dashboard)/products/new/details/page.tsx` +- `src/app/(dashboard)/products/new/pricing/page.tsx` +- `src/app/(dashboard)/products/new/review/page.tsx` +- `src/app/api/file/image/[...path]/route.ts` + +Behavior: +- Added local API proxy for images: + - `GET /api/file/image/{path}` + - forwards to backend `/api/v1.0/file/image/{path}` with auth headers +- Create product step 2 and step 3 reload thumbnails from saved file ids when navigating forward/back. +- Create product final review shows real thumbnails for visual identity and model images. +- Review page was made full width and now displays model image and measurement details more completely. + +### Product create submit debug logging + +File: +- `src/app/api/products/create/route.ts` + +Behavior: +- When `DEBUG_BACKEND_PROXY=true`, create product submit writes `product-create-submit-log.json`. +- The log captures: + - local endpoint + - backend endpoint + - redacted headers + - full request payload + - backend response + +Current known finding from latest capture: +- Payload sent top-level `imageId` and separate `productImages`. +- Backend review detail returned top-level `imageId` from `productImages[0]`, not from request top-level `imageId`. +- Payload sent `productFiles`, but review detail returned `productFiles: []` in the checked response. + +### Dashboard seller metrics + +File: +- `src/app/api/dashboard/seller/route.ts` + +Behavior: +- Fixed parsing of scalar metrics from backend payloads where `data` is an object like `{ total: 96 }`. +- Verified for Pendopo account that dashboard total products can resolve to non-zero. + +### Admin product list/detail + +Files: +- `src/app/admin/products/page.tsx` +- `src/app/admin/products/[productId]/page.tsx` +- `src/app/api/admin/products/route.ts` + +Behavior: +- Admin product list now renders product thumbnails in `All Product` and `Deleted` tabs. +- Thumbnail uses the shared image URL helper. +- Admin product detail now resolves main category by looking up categories/subcategories when backend omits `subCategory.category.name`. +- Admin All Product endpoint was tested with: + - `GET /api/v1.0/admin/product?page=1&size=20` +- Backend returned: + - HTTP `400` + - body `{"responseCode":"000001","data":null,"responseDesc":"Bad Request"}` +- Current proxy behavior: + - tries `/api/v1.0/admin/product` + - if non-deleted tab and the admin endpoint fails, falls back to `/api/v1.0/product` +- Deleted endpoint remains: + - `GET /api/v1.0/admin/deleted/product?page={page}&size={size}` +- Server log showed request reached backend with admin token and failed in about 3 ms, suggesting request validation/contract mismatch rather than auth or DB latency. + +### Seller profile save flow + +Files: +- `src/app/(dashboard)/settings/page.tsx` +- `src/app/api/seller/profile/route.ts` + +Behavior: +- Seller profile edit saves via: + - frontend: `PUT /api/seller/profile` + - backend: `PUT /api/v1.0/seller/store` +- Payload: + - `storeName` + - `storeBiography` + - optional `imageId` when seller avatar was uploaded + - optional `storeImageId` when store image was uploaded + +Example payload: + +```json +{ + "storeName": "...", + "storeBiography": "...", + "imageId": "optional-file-id", + "storeImageId": "optional-file-id" +} +``` + ## Recent Commits - `7e6446b` `Refine seller onboarding and product review flows` diff --git a/src/app/(dashboard)/products/[productId]/detail/page.tsx b/src/app/(dashboard)/products/[productId]/detail/page.tsx index 3cdbafb..b69c004 100644 --- a/src/app/(dashboard)/products/[productId]/detail/page.tsx +++ b/src/app/(dashboard)/products/[productId]/detail/page.tsx @@ -5,8 +5,7 @@ import { useParams, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import { ProductVariantShowcase } from "@/components/product-variant-showcase"; import { useLanguage } from "@/lib/i18n-context"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; +import { resolveBackendImageUrl } from "@/lib/image-url"; interface ProductWarehouse { id?: string; @@ -41,6 +40,7 @@ interface ProductModel { name?: string; sku?: string; imageId?: string; + image?: string; price?: string | number; currency?: string; weight?: string | number; @@ -59,6 +59,7 @@ interface ProductModel { interface ProductImage { sequence?: number; imageId?: string; + image?: string; } interface ProductInfoItem { @@ -91,6 +92,7 @@ interface ProductDetail { preOrderDay?: string | number; description?: string; imageId?: string; + image?: string; productImages?: ProductImage[]; productModels?: ProductModel[]; productKeyWords?: string[]; @@ -136,8 +138,12 @@ function Row({ label, value }: { label: string; value?: string | number | boolea ); } -function isNonEmptyString(value: string | undefined): value is string { - return typeof value === "string" && value.length > 0; +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function resolveImageUrl(imageId?: string | null, image?: string | null) { + return resolveBackendImageUrl({ image, imageId }); } function ToggleBadge({ label, value }: { label: string; value: boolean }) { @@ -298,16 +304,21 @@ function ProductDetailPageInner() { const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : []; const productInfos = Array.isArray(product.productInformations) ? product.productInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : []; const categoryInfos = Array.isArray(product.categoryInformations) ? product.categoryInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : []; - const allImages: string[] = [ - ...(product.imageId ? [product.imageId] : []), + const allImages: Array<{ id?: string; url: string }> = [ + ...(product.imageId || product.image + ? [{ id: product.imageId, url: resolveImageUrl(product.imageId, product.image) }] + : []), ...(Array.isArray(product.productImages) ? product.productImages .sort( (a: ProductImage, b: ProductImage) => (a.sequence ?? 0) - (b.sequence ?? 0) ) - .map((img: ProductImage) => img.imageId) - .filter(isNonEmptyString) + .map((img: ProductImage) => ({ + id: img.imageId, + url: resolveImageUrl(img.imageId, img.image), + })) + .filter((img) => isNonEmptyString(img.url)) : []), ]; const isReviewProduct = isReview || product.state === "REVIEW"; @@ -423,12 +434,12 @@ function ProductDetailPageInner() { ) : (
- {allImages.map((imgId, i) => ( -
+ {allImages.map((img, i) => ( +
{/* eslint-disable-next-line @next/next/no-img-element */} {i { (e.target as HTMLImageElement).style.display = "none"; }} diff --git a/src/app/(dashboard)/products/new/details/page.tsx b/src/app/(dashboard)/products/new/details/page.tsx index a8b2923..7b1a641 100644 --- a/src/app/(dashboard)/products/new/details/page.tsx +++ b/src/app/(dashboard)/products/new/details/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useProductDraft } from "@/lib/product-draft"; import { useProductSubmit } from "@/lib/use-product-submit"; @@ -30,6 +30,52 @@ function ImageSlotItem({ const [uploading, setUploading] = useState(false); const [error, setError] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); + const [persistedPreviewUrl, setPersistedPreviewUrl] = useState(""); + + useEffect(() => { + let objectUrl = ""; + let cancelled = false; + + async function loadPersistedPreview() { + if (!fileId || previewUrl || fileId.startsWith("http")) { + setPersistedPreviewUrl(fileId.startsWith("http") ? fileId : ""); + return; + } + + const candidates = [ + `/api/file/image/${encodeURIComponent(fileId)}`, + `/api/file/image/tmp/${encodeURIComponent(fileId)}`, + ]; + + for (const url of candidates) { + try { + const res = await fetch(url, { + headers: { "x-auth-token": getToken() }, + cache: "no-store", + }); + if (!res.ok) continue; + + const blob = await res.blob(); + if (!blob.type.startsWith("image/")) continue; + + objectUrl = URL.createObjectURL(blob); + if (!cancelled) setPersistedPreviewUrl(objectUrl); + return; + } catch { + // Try the next possible image path. + } + } + + if (!cancelled) setPersistedPreviewUrl(""); + } + + loadPersistedPreview(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [fileId, previewUrl]); async function handleChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -62,7 +108,8 @@ function ImageSlotItem({ } } - const hasImage = fileId || previewUrl; + const displayPreviewUrl = previewUrl || persistedPreviewUrl; + const hasImage = fileId || displayPreviewUrl; return (
@@ -70,8 +117,8 @@ function ImageSlotItem({ className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden cursor-pointer" onClick={() => inputRef.current?.click()} > - {previewUrl ? ( - + {displayPreviewUrl ? ( + ) : ( { + let objectUrl = ""; + let cancelled = false; + + async function loadPersistedPreview() { + if (!value || previewUrl || value.startsWith("http")) { + setPersistedPreviewUrl(value.startsWith("http") ? value : ""); + return; + } + + const candidates = [ + `/api/file/image/${encodeURIComponent(value)}`, + `/api/file/image/tmp/${encodeURIComponent(value)}`, + ]; + + for (const url of candidates) { + try { + const res = await fetch(url, { + headers: { "x-auth-token": getToken() }, + cache: "no-store", + }); + if (!res.ok) continue; + + const blob = await res.blob(); + if (!blob.type.startsWith("image/")) continue; + + objectUrl = URL.createObjectURL(blob); + if (!cancelled) setPersistedPreviewUrl(objectUrl); + return; + } catch { + // Try the next possible image path. + } + } + + if (!cancelled) setPersistedPreviewUrl(""); + } + + loadPersistedPreview(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [value, previewUrl]); async function handleChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -123,7 +169,8 @@ function ModelImageUpload({ } } - const hasImage = value || previewUrl; + const displayPreviewUrl = previewUrl || persistedPreviewUrl; + const hasImage = value || displayPreviewUrl; return (
- {previewUrl ? ( - Model preview + {displayPreviewUrl ? ( + Model preview ) : (
{ + let objectUrl = ""; + let cancelled = false; + + async function loadImage() { + if (!imageId) { + setSrc(""); + return; + } + + if (imageId.startsWith("http")) { + setSrc(imageId); + return; + } + + const token = getToken(); + const candidates = [ + `/api/file/image/${encodeURIComponent(imageId)}`, + `/api/file/image/tmp/${encodeURIComponent(imageId)}`, + ]; + + for (const url of candidates) { + try { + const res = await fetch(url, { + headers: { "x-auth-token": token }, + cache: "no-store", + }); + + if (!res.ok) continue; + + const blob = await res.blob(); + if (!blob.type.startsWith("image/")) continue; + + objectUrl = URL.createObjectURL(blob); + if (!cancelled) setSrc(objectUrl); + return; + } catch { + // Try the next candidate. + } + } + + if (!cancelled) setSrc(""); + } + + loadImage(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [imageId]); + + if (!src) { + return ( +
+ N/A +
+ ); + } + + // eslint-disable-next-line @next/next/no-img-element + return {alt}; +} + export default function ProductReviewPage() { const router = useRouter(); const { draft } = useProductDraft(); @@ -117,7 +184,7 @@ export default function ProductReviewPage() { } return ( -
+
{/* Header */}
@@ -172,19 +239,15 @@ export default function ProductReviewPage() {
{reviewImageIds.map((imageId, index) => { - const url = imgUrl(imageId); - if (!url) return null; const isMainImage = index === 0 && Boolean(draft.imageId); return (
- {/* eslint-disable-next-line @next/next/no-img-element */} - {isMainImage

{isMainImage ? r.mainImage : `${r.gallery} ${draft.imageId ? index : index + 1}`} @@ -216,53 +279,81 @@ export default function ProductReviewPage() { 03 · {r.section03} ({draft.models.length} {r.model})

{draft.models.map((model, idx) => ( -
+
{/* Model header */} -
-
{idx + 1}
-

{model.name || `Model ${idx + 1}`}

- {model.sku && SKU: {model.sku}} -
+
+
+ {model.imageId ? ( +
+ +

+ Model Image +

+
+ ) : ( +
+ No Model Image +
+ )} +
- {/* Model core info */} -
- - - - {model.hasPromotion && } - {model.hasPromotion && model.promotionStartDate && ( - - )} - - +
+
+
{idx + 1}
+

{model.name || `Model ${idx + 1}`}

+ {model.sku && SKU: {model.sku}} + {model.hasMeasurements && ( + + {r.measurements} + + )} +
+ + {/* Model core info */} +
+ + + + {model.hasPromotion && } + {model.hasPromotion && model.promotionStartDate && ( + + )} + + +
+
{/* Warehouse stock */} {!model.hasMeasurements && model.warehouses.filter((w) => w.id).length > 0 && ( -
+

{r.warehouseStock}

{model.warehouses.filter((w) => w.id).map((w, wi) => (
@@ -281,7 +372,7 @@ export default function ProductReviewPage() {

{model.measurements.map((ms, mi) => ( -
+
{ms.measurementType || `${r.variantLabel} ${mi + 1}`} @@ -292,7 +383,7 @@ export default function ProductReviewPage() { )}
-
+
@@ -301,6 +392,7 @@ export default function ProductReviewPage() { )} +
{ms.warehouses.filter((w) => w.id).length > 0 && (
diff --git a/src/app/admin/news/[newsId]/edit/page.tsx b/src/app/admin/news/[newsId]/edit/page.tsx index f09146e..4e41286 100644 --- a/src/app/admin/news/[newsId]/edit/page.tsx +++ b/src/app/admin/news/[newsId]/edit/page.tsx @@ -2,8 +2,7 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id"; +import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; interface NewsForm { title: string; @@ -36,8 +35,7 @@ function extractImageUrl(data: Record): string | null { data?.url || (data?.data as Record)?.url; if (!filename) return null; - if (typeof filename === "string" && filename.startsWith("http")) return filename; - return `${API_BASE}/api/v1.0/file/image/${filename}`; + return resolveBackendImageUrlFromValue(String(filename)); } const inputCls = diff --git a/src/app/admin/news/new/page.tsx b/src/app/admin/news/new/page.tsx index e5c4b8e..d91f6d2 100644 --- a/src/app/admin/news/new/page.tsx +++ b/src/app/admin/news/new/page.tsx @@ -2,8 +2,7 @@ import { useRouter } from "next/navigation"; import { useRef, useState } from "react"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id"; +import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; interface NewsForm { title: string; @@ -37,9 +36,7 @@ function extractImageUrl(data: Record): string | null { data?.url || (data?.data as Record)?.url; if (!filename) return null; - // If already a full URL, return as-is - if (typeof filename === "string" && filename.startsWith("http")) return filename; - return `${API_BASE}/api/v1.0/file/image/${filename}`; + return resolveBackendImageUrlFromValue(String(filename)); } const inputCls = diff --git a/src/app/admin/places/PlaceForm.tsx b/src/app/admin/places/PlaceForm.tsx index d9c5e38..9cd1c48 100644 --- a/src/app/admin/places/PlaceForm.tsx +++ b/src/app/admin/places/PlaceForm.tsx @@ -3,8 +3,7 @@ import { useRef, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { COUNTRIES } from "@/lib/countries"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id"; +import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; function extractImageUrl(data: Record): string | null { const filename = @@ -16,8 +15,7 @@ function extractImageUrl(data: Record): string | null { data?.url || (data?.data as Record)?.url; if (!filename) return null; - if (typeof filename === "string" && filename.startsWith("http")) return filename; - return `${API_BASE}/api/v1.0/file/image/${filename}`; + return resolveBackendImageUrlFromValue(String(filename)); } export interface PlaceFormState { diff --git a/src/app/admin/products/[productId]/page.tsx b/src/app/admin/products/[productId]/page.tsx index 80f97d0..2873ce3 100644 --- a/src/app/admin/products/[productId]/page.tsx +++ b/src/app/admin/products/[productId]/page.tsx @@ -4,8 +4,7 @@ import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { ProductVariantShowcase } from "@/components/product-variant-showcase"; - -const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; +import { resolveBackendImageUrl } from "@/lib/image-url"; interface ProductWarehouse { id?: string; @@ -36,6 +35,7 @@ interface ProductModel { name?: string; sku?: string; imageId?: string; + image?: string; warehouses?: ProductWarehouse[]; productMeasurements?: ProductMeasurement[]; } @@ -43,6 +43,7 @@ interface ProductModel { interface ProductImage { sequence?: number; imageId?: string; + image?: string; } interface ProductInfoItem { @@ -50,6 +51,11 @@ interface ProductInfoItem { paramValue: string; } +interface CategoryOption { + id: string; + name: string; +} + interface ProductCategory { name?: string; } @@ -65,6 +71,7 @@ interface ProductDetail { state?: string; description?: string; imageId?: string; + image?: string; subCategory?: ProductSubCategory; isPreOrder?: boolean; isNew?: boolean; @@ -90,6 +97,7 @@ interface ProductDetail { id?: string; name?: string; imageId?: string; + image?: string; }; } @@ -98,10 +106,8 @@ function getToken() { return sessionStorage.getItem("token") || localStorage.getItem("token") || ""; } -function imageUrl(imageId?: string | null) { - if (!imageId) return null; - if (imageId.startsWith("http")) return imageId; - return `${API_BASE}/api/v1.0/file/image/${imageId}`; +function imageUrl(imageId?: string | null, image?: string | null) { + return resolveBackendImageUrl({ image, imageId }) || null; } function SectionHeader({ step, title }: { step: string; title: string }) { @@ -207,6 +213,7 @@ export default function AdminProductDetailPage() { const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [resolvedMainCategoryName, setResolvedMainCategoryName] = useState(""); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); @@ -227,6 +234,56 @@ export default function AdminProductDetailPage() { .finally(() => setLoading(false)); }, [params.productId]); + useEffect(() => { + const subCategoryId = product?.subCategory?.id; + + if (!subCategoryId || product?.subCategory?.category?.name) { + return; + } + + let cancelled = false; + + async function resolveMainCategory() { + try { + const token = getToken(); + const categoriesRes = await fetch("/api/products/categories?size=100", { + headers: { "x-auth-token": token }, + }); + const categoriesJson = await categoriesRes.json().catch(() => ({})); + const categories: CategoryOption[] = Array.isArray(categoriesJson?.rows) + ? categoriesJson.rows + : []; + + for (const category of categories) { + const subcategoriesRes = await fetch( + `/api/products/subcategories/${category.id}?size=100`, + { headers: { "x-auth-token": token } } + ); + const subcategoriesJson = await subcategoriesRes.json().catch(() => ({})); + const rows: CategoryOption[] = Array.isArray(subcategoriesJson?.rows) + ? subcategoriesJson.rows + : []; + if (rows.some((subCategory) => subCategory.id === subCategoryId)) { + if (!cancelled) { + setResolvedMainCategoryName(category.name); + } + return; + } + } + + if (!cancelled) setResolvedMainCategoryName(""); + } catch { + if (!cancelled) setResolvedMainCategoryName(""); + } + } + + resolveMainCategory(); + + return () => { + cancelled = true; + }; + }, [product?.subCategory?.category?.name, product?.subCategory?.id]); + async function handleDelete() { if (!params.productId) return; setDeleting(true); @@ -275,12 +332,14 @@ export default function AdminProductDetailPage() { ? product.categoryInformations.filter((item) => item.paramName && item.paramValue) : []; const allImages = [ - ...(product.imageId ? [product.imageId] : []), + ...(product.imageId || product.image + ? [{ id: product.imageId, url: imageUrl(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: imageUrl(item.imageId, item.image) || "" })) + .filter((item) => Boolean(item.url)) : []), ]; @@ -338,7 +397,7 @@ export default function AdminProductDetailPage() {

Main Category

-

{product.subCategory?.category?.name || "—"}

+

{product.subCategory?.category?.name || resolvedMainCategoryName || "—"}

Sub Category

@@ -411,13 +470,13 @@ export default function AdminProductDetailPage() { {allImages.length ? (
- {allImages.map((imageId, index) => { - const src = imageUrl(imageId); + {allImages.map((image, index) => { + const src = image.url; if (!src) return null; return ( // eslint-disable-next-line @next/next/no-img-element {`${product.name
- {imageUrl(product.seller.imageId) ? ( + {imageUrl(product.seller.imageId, product.seller.image) ? ( // eslint-disable-next-line @next/next/no-img-element {product.seller.name 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 + {product.name} + ) : ( + 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.name} )}

{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 - {`img-${i}`} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`img-${i}`} +
); })}
@@ -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 {`${product.name
- {imgUrl(product.seller.imageId) ? ( + {imgUrl(product.seller.imageId, product.seller.image) ? ( // eslint-disable-next-line @next/next/no-img-element {product.seller.name @@ -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 - {product.seller.name} + {product.seller.name} ) : (
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 */ {item.name} 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 {selectedModel?.name @@ -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 */} {`gallery-${index
@@ -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