Refine admin review and product image handling

This commit is contained in:
2026-05-28 13:34:23 +07:00
parent 4c125a5326
commit ebef73f40e
20 changed files with 1123 additions and 277 deletions

View File

@ -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`

View File

@ -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() {
</div>
) : (
<div className="space-y-3">
{allImages.map((imgId, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
{allImages.map((img, i) => (
<div key={`${img.id || img.url}-${i}`} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
<div className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${API_BASE}/api/v1.0/file/image/${imgId}`}
src={img.url}
alt={i === 0 ? "Main Image" : `Gallery ${i}`}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}

View File

@ -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<HTMLInputElement>) {
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 (
<div className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
@ -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 ? (
<img src={previewUrl} alt="" className="w-full h-full object-cover" />
{displayPreviewUrl ? (
<img src={displayPreviewUrl} alt="" className="w-full h-full object-cover" />
) : (
<span
className="material-symbols-outlined text-2xl"

View File

@ -91,6 +91,52 @@ function ModelImageUpload({
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 (!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<HTMLInputElement>) {
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 (
<div
@ -133,8 +180,8 @@ function ModelImageUpload({
{hasImage ? (
<>
<div className="absolute inset-0 flex items-center justify-center">
{previewUrl ? (
<img src={previewUrl} alt="Model preview" className="w-full h-full object-cover" />
{displayPreviewUrl ? (
<img src={displayPreviewUrl} alt="Model preview" className="w-full h-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2">
<span

View File

@ -6,19 +6,11 @@ import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context";
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 toNumber(value: string) {
const normalized = value.replace(/\./g, "").replace(/,/g, ".");
const parsed = Number(normalized);
@ -61,6 +53,81 @@ function Badge({ children }: { children: React.ReactNode }) {
);
}
function ReviewImage({
imageId,
alt,
className = "h-20 w-full rounded-lg object-cover bg-surface-container",
}: {
imageId: string;
alt: string;
className?: string;
}) {
const [src, setSrc] = useState("");
useEffect(() => {
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 (
<div className={`flex items-center justify-center bg-surface-container text-[10px] font-black text-outline ${className}`}>
N/A
</div>
);
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt={alt} className={className} />;
}
export default function ProductReviewPage() {
const router = useRouter();
const { draft } = useProductDraft();
@ -117,7 +184,7 @@ export default function ProductReviewPage() {
}
return (
<div className="max-w-3xl space-y-6">
<div className="w-full max-w-none space-y-6 pb-32">
{/* Header */}
<div className="flex items-center justify-between">
<div>
@ -172,19 +239,15 @@ export default function ProductReviewPage() {
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-3">
{reviewImageIds.map((imageId, index) => {
const url = imgUrl(imageId);
if (!url) return null;
const isMainImage = index === 0 && Boolean(draft.imageId);
return (
<div
key={`${imageId}-${index}`}
className="w-28 rounded-xl border border-outline-variant/10 bg-surface-container-low p-2"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
<ReviewImage
imageId={imageId}
alt={isMainImage ? r.mainImage : `${r.gallery} ${index}`}
className="h-20 w-full rounded-lg object-cover bg-surface-container"
/>
<p className="mt-2 text-[10px] font-black uppercase tracking-widest text-outline">
{isMainImage ? r.mainImage : `${r.gallery} ${draft.imageId ? index : index + 1}`}
@ -216,53 +279,81 @@ export default function ProductReviewPage() {
<SectionTitle>03 · {r.section03} ({draft.models.length} {r.model})</SectionTitle>
<div className="space-y-5">
{draft.models.map((model, idx) => (
<div key={model.id} className="rounded-xl border border-outline-variant/10 bg-surface-container-low p-4 space-y-3">
<div key={model.id} className="rounded-xl border border-outline-variant/10 bg-surface-container-low p-5 space-y-4">
{/* Model header */}
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white text-[10px] font-black">{idx + 1}</div>
<p className="text-sm font-black text-on-surface">{model.name || `Model ${idx + 1}`}</p>
{model.sku && <span className="text-[10px] text-outline font-bold">SKU: {model.sku}</span>}
</div>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<div className="w-full lg:w-48 flex-shrink-0">
{model.imageId ? (
<div className="rounded-xl border border-outline-variant/10 bg-surface-container-lowest p-2">
<ReviewImage
imageId={model.imageId}
alt={model.name || `Model ${idx + 1}`}
className="h-32 w-full rounded-lg object-cover bg-surface-container"
/>
<p className="mt-2 text-[10px] font-black uppercase tracking-widest text-outline">
Model Image
</p>
</div>
) : (
<div className="flex h-40 w-full items-center justify-center rounded-xl border border-dashed border-outline-variant/30 bg-surface-container-lowest text-[10px] font-black uppercase tracking-widest text-outline">
No Model Image
</div>
)}
</div>
{/* Model core info */}
<div className="grid grid-cols-2 gap-x-6">
<Row
label={r.price}
value={
model.hasMeasurements
? "Using measurement variants"
: `${model.currency || "IDR"} ${formatIDR(model.price)}`
}
yes={r.yes}
no={r.no}
/>
<Row
label={`${r.weight} (${model.weightType || "G"})`}
value={model.hasMeasurements ? "Using measurement variants" : model.weight ? `${model.weight}` : undefined}
yes={r.yes}
no={r.no}
/>
<Row
label={`${r.dimensions} (${model.dimensionType || "CM"})`}
value={
model.hasMeasurements
? "Using measurement variants"
: [model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined
}
yes={r.yes}
no={r.no}
/>
{model.hasPromotion && <Row label={r.promoPrice} value={`${model.promotionCurrency || model.currency || "IDR"} ${formatIDR(model.promotionPrice)}`} yes={r.yes} no={r.no} />}
{model.hasPromotion && model.promotionStartDate && (
<Row label={r.promoPeriod} value={`${model.promotionStartDate}${model.promotionEndDate}`} yes={r.yes} no={r.no} />
)}
<Row label={`${r.packagingWeight} (${model.packagingWeightType || "G"})`} value={model.packagingWeight || undefined} yes={r.yes} no={r.no} />
<Row label={`${r.packagingDimensions} (${model.packagingDimensionType || "CM"})`} value={[model.packagingLength, model.packagingWidth, model.packagingHeight].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
<div className="min-w-0 flex-1 space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white text-[10px] font-black">{idx + 1}</div>
<p className="text-base font-black text-on-surface">{model.name || `Model ${idx + 1}`}</p>
{model.sku && <span className="text-[10px] text-outline font-bold">SKU: {model.sku}</span>}
{model.hasMeasurements && (
<span className="rounded-full bg-secondary-container/70 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-on-secondary-container">
{r.measurements}
</span>
)}
</div>
{/* Model core info */}
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-2 xl:grid-cols-3">
<Row
label={r.price}
value={
model.hasMeasurements
? "Using measurement variants"
: `${model.currency || "IDR"} ${formatIDR(model.price)}`
}
yes={r.yes}
no={r.no}
/>
<Row
label={`${r.weight} (${model.weightType || "G"})`}
value={model.hasMeasurements ? "Using measurement variants" : model.weight ? `${model.weight}` : undefined}
yes={r.yes}
no={r.no}
/>
<Row
label={`${r.dimensions} (${model.dimensionType || "CM"})`}
value={
model.hasMeasurements
? "Using measurement variants"
: [model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined
}
yes={r.yes}
no={r.no}
/>
{model.hasPromotion && <Row label={r.promoPrice} value={`${model.promotionCurrency || model.currency || "IDR"} ${formatIDR(model.promotionPrice)}`} yes={r.yes} no={r.no} />}
{model.hasPromotion && model.promotionStartDate && (
<Row label={r.promoPeriod} value={`${model.promotionStartDate}${model.promotionEndDate}`} yes={r.yes} no={r.no} />
)}
<Row label={`${r.packagingWeight} (${model.packagingWeightType || "G"})`} value={model.packagingWeight || undefined} yes={r.yes} no={r.no} />
<Row label={`${r.packagingDimensions} (${model.packagingDimensionType || "CM"})`} value={[model.packagingLength, model.packagingWidth, model.packagingHeight].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
</div>
</div>
</div>
{/* Warehouse stock */}
{!model.hasMeasurements && model.warehouses.filter((w) => w.id).length > 0 && (
<div>
<div className="rounded-xl bg-surface-container-lowest p-4">
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-1">{r.warehouseStock}</p>
{model.warehouses.filter((w) => w.id).map((w, wi) => (
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
@ -281,7 +372,7 @@ export default function ProductReviewPage() {
</p>
<div className="space-y-2">
{model.measurements.map((ms, mi) => (
<div key={ms.id} className="rounded-lg bg-surface-container-lowest border border-outline-variant/10 p-3">
<div key={ms.id} className="rounded-xl bg-surface-container-lowest border border-outline-variant/10 p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-black text-primary uppercase tracking-wider">
{ms.measurementType || `${r.variantLabel} ${mi + 1}`}
@ -292,7 +383,7 @@ export default function ProductReviewPage() {
</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-2 xl:grid-cols-3">
<Row label={r.price} value={`${ms.currency || "IDR"} ${formatIDR(ms.price)}`} yes={r.yes} no={r.no} />
<Row label={`${r.weight} (${ms.weightType || "G"})`} value={ms.weight || undefined} yes={r.yes} no={r.no} />
<Row label={`${r.dimensions} (${ms.dimensionType || "CM"})`} value={[ms.length, ms.width, ms.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
@ -301,6 +392,7 @@ export default function ProductReviewPage() {
<Row label={r.promoPeriod} value={`${ms.promotionStartDate}${ms.promotionEndDate}`} yes={r.yes} no={r.no} />
)}
<Row label={`${r.packagingWeight} (${ms.packagingWeightType || "G"})`} value={ms.packagingWeight || undefined} yes={r.yes} no={r.no} />
<Row label={`${r.packagingDimensions} (${ms.packagingDimensionType || "CM"})`} value={[ms.packagingLength, ms.packagingWidth, ms.packagingHeight].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
</div>
{ms.warehouses.filter((w) => w.id).length > 0 && (
<div className="mt-1.5">

View File

@ -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, unknown>): string | null {
data?.url ||
(data?.data as Record<string, unknown>)?.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 =

View File

@ -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, unknown>): string | null {
data?.url ||
(data?.data as Record<string, unknown>)?.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 =

View File

@ -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, unknown>): string | null {
const filename =
@ -16,8 +15,7 @@ function extractImageUrl(data: Record<string, unknown>): string | null {
data?.url ||
(data?.data as Record<string, unknown>)?.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 {

View File

@ -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<ProductDetail | null>(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() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Main Category</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || resolvedMainCategoryName || "—"}</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Sub Category</p>
@ -411,13 +470,13 @@ export default function AdminProductDetailPage() {
<SectionHeader step="03" title="Gallery" />
{allImages.length ? (
<div className="grid grid-cols-2 gap-3">
{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
<img
key={`${imageId}-${index}`}
key={`${image.id || src}-${index}`}
src={src}
alt={`${product.name || "product"}-${index + 1}`}
className="h-32 w-full rounded-xl border border-surface-container object-cover"
@ -436,10 +495,10 @@ export default function AdminProductDetailPage() {
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="04" title="Seller" />
<div className="flex items-center gap-4">
{imageUrl(product.seller.imageId) ? (
{imageUrl(product.seller.imageId, product.seller.image) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl(product.seller.imageId) || ""}
src={imageUrl(product.seller.imageId, product.seller.image) || ""}
alt={product.seller.name || "seller"}
className="h-14 w-14 rounded-full border border-surface-container object-cover"
/>

View File

@ -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 (
<tr key={product.id} className="group hover:bg-surface-container-low transition-colors">
<td className="px-6 py-5">
<div>
<p
className={`font-extrabold transition-colors group-hover:text-primary ${
isInactiveInAllTab ? "text-slate-400 line-through" : "text-on-surface"
}`}
>
{product.name}
</p>
<p className="mt-1 text-[10px] font-medium text-slate-400">ID: {product.id.slice(0, 8)}</p>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-slate-100">
{product.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveBackendImageUrlFromValue(product.image)}
alt={product.name}
className={`h-full w-full object-cover ${isInactiveInAllTab ? "grayscale" : ""}`}
/>
) : (
<span className="text-[10px] font-black text-slate-400">N/A</span>
)}
</div>
<div className="min-w-0">
<p
className={`font-extrabold transition-colors group-hover:text-primary ${
isInactiveInAllTab ? "text-slate-400 line-through" : "text-on-surface"
}`}
>
{product.name}
</p>
<p className="mt-1 text-[10px] font-medium text-slate-400">ID: {product.id.slice(0, 8)}</p>
</div>
</div>
</td>
<td className="px-6 py-5">

View File

@ -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 (
<div className={`rounded-2xl border p-5 ${changed ? "border-amber-300 bg-amber-50/70" : "border-surface-container bg-surface-container-low"}`}>
<div className="flex items-center gap-3 mb-3">
{imgUrl(model.imageId) && (
{imgUrl(model.imageId, model.image) && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgUrl(model.imageId)!} alt={model.name} className="h-12 w-12 object-cover rounded-xl border border-surface-container" />
<img src={imgUrl(model.imageId, model.image)!} alt={model.name} className="h-12 w-12 object-cover rounded-xl border border-surface-container" />
)}
<div>
<p className="font-black text-sm text-on-surface">{model.name || `Model ${index + 1}`}</p>
@ -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<string, unknown>, field: string, value: unknown) {
const segments = parseFieldSegments(field);
let cursor: Record<string, unknown> | unknown[] = target;
segments.forEach((segment, index) => {
const isLast = index === segments.length - 1;
const current = cursor as Record<string, unknown>;
if (segment.selectorKey) {
if (!Array.isArray(current[segment.name])) current[segment.name] = [];
const list = current[segment.name] as Array<Record<string, unknown>>;
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<string, unknown>;
}
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<string, unknown>;
});
}
function compactCompareProduct(product: Record<string, unknown>): 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<string, unknown>).some(
([entryKey, entryValue]) =>
entryKey !== "__compareKey" &&
entryValue !== null &&
entryValue !== undefined &&
entryValue !== ""
);
});
product[key] = (product[key] as Record<string, unknown>[]).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<string, unknown> = {};
const newProduct: Record<string, unknown> = {};
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 (
<div className="flex-1 flex items-center justify-center py-20 text-slate-400 text-sm">Memuat data...</div>
@ -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 (
<div className="flex-1 min-w-0 space-y-4">
<div className={`px-5 py-4 rounded-2xl text-[11px] font-black uppercase tracking-[0.18em] ${accent ? "bg-primary text-white" : "bg-surface-container-low text-on-surface-variant"}`}>
{label}
</div>
{showLabel ? (
<div className={`px-5 py-4 rounded-2xl text-[11px] font-black uppercase tracking-[0.18em] ${accent ? "bg-primary text-white" : "bg-surface-container-low text-on-surface-variant"}`}>
{label}
</div>
) : null}
{allImages.length > 0 && (
<SectionCard title="Gambar Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}>
<div className="grid grid-cols-2 gap-3">
{allImages.map((imageId: string, i: number) => {
const url = imgUrl(imageId);
{shouldRender("images") && allImages.length > 0 && (
<SectionCard title={sectionTitle || "Gambar Produk"} accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6">
{allImages.map((image, i: number) => {
const url = image.url;
if (!url) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img key={i} src={url} alt={`img-${i}`} className="h-32 w-full object-cover rounded-xl border border-surface-container" />
<div key={`${image.id || url}-${i}`} className="flex aspect-square items-center justify-center overflow-hidden rounded-xl border border-surface-container bg-surface-container-low p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`img-${i}`} className="h-full w-full object-contain" />
</div>
);
})}
</div>
@ -432,8 +614,9 @@ function ProductColumn({
)}
{/* Basic Info */}
<SectionCard
title="Detail Produk"
{shouldRender("details") ? (
<SectionCard
title={sectionTitle || "Detail Produk"}
accent={accent}
changed={hasChangesForPaths(compareRows, [
"name",
@ -443,7 +626,6 @@ function ProductColumn({
"isEligibleToExport",
"isPreOrder",
"preOrderDay",
"state",
])}
>
<Row label="Nama" value={product.name} />
@ -454,12 +636,12 @@ function ProductColumn({
<Row label="Bisa Diekspor" value={product.isEligibleToExport} />
<Row label="Pre-order" value={product.isPreOrder} />
{product.isPreOrder && <Row label="Durasi Pre-order" value={product.preOrderDay} />}
<Row label="Status" value={product.state} />
</SectionCard>
</SectionCard>
) : null}
{/* Features */}
{features.length > 0 && (
<SectionCard title="Fitur Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["productFeatures"])}>
{shouldRender("features") && features.length > 0 && (
<SectionCard title={sectionTitle || "Fitur Produk"} accent={accent} changed={hasChangesForPaths(compareRows, ["productFeatures"])}>
<div className="flex flex-wrap gap-2">
{features.map((f: string, i: number) => (
<span key={i} className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed">{f}</span>
@ -469,8 +651,8 @@ function ProductColumn({
)}
{/* Keywords */}
{keywords.length > 0 && (
<SectionCard title="Kata Kunci" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
{shouldRender("keywords") && keywords.length > 0 && (
<SectionCard title={sectionTitle || "Kata Kunci"} accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
<div className="flex flex-wrap gap-2">
{keywords.map((k: string) => (
<span key={k} className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary">{k}</span>
@ -479,9 +661,49 @@ function ProductColumn({
</SectionCard>
)}
{shouldRender("info") && (productInfos.length > 0 || categoryInfos.length > 0) && (
<SectionCard
title={sectionTitle || "Informasi Tambahan"}
accent={accent}
changed={hasChangesForPaths(compareRows, ["productInformations", "categoryInformations"])}
>
{productInfos.length > 0 ? (
<div className="space-y-1">
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Informasi Produk</p>
{productInfos.map((item, index) => (
<Row key={`${item.paramName}-${index}`} label={item.paramName} value={item.paramValue} />
))}
</div>
) : null}
{categoryInfos.length > 0 ? (
<div className={productInfos.length > 0 ? "mt-5 space-y-1" : "space-y-1"}>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Informasi Kategori</p>
{categoryInfos.map((item, index) => (
<Row key={`${item.paramName}-${index}`} label={item.paramName} value={item.paramValue} />
))}
</div>
) : null}
</SectionCard>
)}
{shouldRender("files") && productFiles.length > 0 && (
<SectionCard title={sectionTitle || "Dokumen Produk"} accent={accent} changed={hasChangesForPaths(compareRows, ["productFiles"])}>
<div className="space-y-2">
{productFiles.map((file, index) => {
const label = file.fileId || file.file || file.id || `Dokumen ${index + 1}`;
return (
<div key={`${file.id || label}-${index}`} className="rounded-xl bg-surface-container-low px-4 py-3 text-sm font-semibold text-on-surface">
{label}
</div>
);
})}
</div>
</SectionCard>
)}
{/* Models */}
{models.length > 0 && (
<SectionCard title={`Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}>
{shouldRender("models") && models.length > 0 && (
<SectionCard title={sectionTitle || `Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}>
<ProductVariantShowcase
product={product}
warehouseLabelResolver={(warehouse) =>
@ -492,8 +714,8 @@ function ProductColumn({
)}
{/* Compliance */}
{product.complianceInformation && (
<SectionCard title="Kepatuhan" accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
{shouldRender("compliance") && product.complianceInformation && (
<SectionCard title={sectionTitle || "Kepatuhan"} accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
<Row label="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
<Row label="Peringatan Keamanan" value={product.complianceInformation.safetyWarning} />
<Row label="Barang Berbahaya" value={product.complianceInformation.isDangerousGoodRegulation} />
@ -501,8 +723,8 @@ function ProductColumn({
)}
{/* Warranty */}
{product.warrantyInformation && (
<SectionCard title="Garansi" accent={accent} changed={hasChangesForPaths(compareRows, ["warrantyInformation"])}>
{shouldRender("warranty") && product.warrantyInformation && (
<SectionCard title={sectionTitle || "Garansi"} accent={accent} changed={hasChangesForPaths(compareRows, ["warrantyInformation"])}>
<Row label="Tipe" value={product.warrantyInformation.type} />
<Row label="Durasi" value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
</SectionCard>
@ -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 (
<div className="space-y-4">
<ProductColumn
product={product}
label={`${title} (Diajukan)`}
accent
compareRows={compareRows}
section={section}
sectionTitle={`${title} (Diajukan)`}
showLabel={false}
/>
<ProductColumn
product={oldProduct}
label={`${title} (Live Saat Ini)`}
section={section}
sectionTitle={`${title} (Live Saat Ini)`}
showLabel={false}
/>
</div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────
function AdminReviewDetailPageInner() {
@ -524,6 +828,7 @@ function AdminReviewDetailPageInner() {
const [oldProduct, setOldProduct] = useState<ReviewProductData | null>(null); // original (compare)
const [isComparison, setIsComparison] = useState(false);
const [isUpdateProduct, setIsUpdateProduct] = useState(false);
const [reviewActionId, setReviewActionId] = useState("");
const [compareRows, setCompareRows] = useState<CompareRow[]>([]);
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 ? (
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="02" title="Versi Diajukan" />
<ProductColumn
product={product}
label="Versi Terbaru (Diajukan)"
accent
compareRows={compareRows}
/>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="03" title="Versi Live Saat Ini" />
<ProductColumn
product={oldProduct}
label="Versi Saat Ini (Live)"
/>
</div>
<div className="grid grid-cols-1 gap-8">
<CompareSectionPair section="images" title="Gambar Produk" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="details" title="Detail Produk" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="features" title="Fitur Produk" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="keywords" title="Kata Kunci" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="info" title="Informasi Tambahan" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="files" title="Dokumen Produk" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="models" title="Model & Harga" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="compliance" title="Kepatuhan" product={product} oldProduct={oldProduct} compareRows={compareRows} />
<CompareSectionPair section="warranty" title="Garansi" product={product} oldProduct={oldProduct} compareRows={compareRows} />
</div>
) : (
<>
@ -903,13 +1191,13 @@ function AdminReviewDetailPageInner() {
<SectionHeader step="04" title="Galeri" />
{allImages.length ? (
<div className="grid grid-cols-2 gap-3">
{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
<img
key={`${imageId}-${index}`}
key={`${image.id || src}-${index}`}
src={src}
alt={`${product.name || "product"}-${index + 1}`}
className="h-32 w-full rounded-xl border border-surface-container object-cover"
@ -928,10 +1216,10 @@ function AdminReviewDetailPageInner() {
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="05" title="Penjual" />
<div className="flex items-center gap-4">
{imgUrl(product.seller.imageId) ? (
{imgUrl(product.seller.imageId, product.seller.image) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imgUrl(product.seller.imageId) || ""}
src={imgUrl(product.seller.imageId, product.seller.image) || ""}
alt={product.seller.name || "seller"}
className="h-14 w-14 rounded-full border border-surface-container object-cover"
/>
@ -1028,9 +1316,9 @@ function AdminReviewDetailPageInner() {
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step={isComparison ? "04" : "03"} title="Penjual" />
<div className="flex items-center gap-4">
{imgUrl(product.seller.imageId) ? (
{imgUrl(product.seller.imageId, product.seller.image) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgUrl(product.seller.imageId)!} alt={product.seller.name} className="w-12 h-12 rounded-full object-cover border border-slate-100" />
<img src={imgUrl(product.seller.imageId, product.seller.image)!} alt={product.seller.name} className="w-12 h-12 rounded-full object-cover border border-slate-100" />
) : (
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-primary">storefront</span>

View File

@ -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 */
<img
src={item.image.startsWith("http") ? item.image : `${API_BASE}/api/v1.0/file/image/${item.image}`}
src={resolveBackendImageUrlFromValue(item.image)}
alt={item.name}
className="w-full h-full object-cover mix-blend-multiply opacity-80"
/>

View File

@ -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 });
}

View File

@ -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 &&

View File

@ -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<string, unknown>;
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;
}
}
}

View File

@ -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",
},
});
}

View File

@ -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 });
}

View File

@ -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 (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5 shadow-sm xl:col-span-4">
<div className="grid grid-cols-1 gap-6">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5 shadow-sm">
<p className="mb-4 text-[10px] font-black uppercase tracking-[0.18em] text-outline">{l.summaryTitle}</p>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{l.productPrice}</p>
<p className="mt-2 text-lg font-black text-on-surface">{getEffectivePriceLabel(models) || "-"}</p>
@ -183,10 +173,10 @@ export function ProductVariantShowcase({
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5 shadow-sm xl:col-span-8">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5 shadow-sm">
<div className="grid grid-cols-1 gap-5 2xl:grid-cols-[minmax(240px,360px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-2xl bg-surface-container-low">
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden rounded-2xl bg-surface-container-low 2xl:aspect-square">
{selectedImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={selectedImageUrl} alt={selectedModel?.name || "variant"} className="h-full w-full object-cover" />
@ -196,11 +186,11 @@ export function ProductVariantShowcase({
</div>
{productImages.length > 1 ? (
<div className="grid grid-cols-4 gap-2">
{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 (
<div key={`${imageId}-${index}`} className="aspect-square overflow-hidden rounded-xl border border-surface-container bg-surface-container-low">
<div key={`${imageRef.id || imageRef.url}-${index}`} className="aspect-square overflow-hidden rounded-xl border border-surface-container bg-surface-container-low">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={`gallery-${index + 1}`} className="h-full w-full object-cover" />
</div>
@ -213,11 +203,11 @@ export function ProductVariantShowcase({
<div className="space-y-5">
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">{l.modelLabel}</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{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 (
<button
key={`${model.sku || model.name || index}`}
@ -226,7 +216,7 @@ export function ProductVariantShowcase({
setSelectedModelIndex(index);
setSelectedMeasurementIndex(0);
}}
className={`flex items-center gap-3 rounded-2xl border p-3 text-left transition-all ${
className={`flex min-w-0 items-center gap-3 rounded-2xl border p-3 text-left transition-all ${
active
? "border-primary bg-primary/5 shadow-sm"
: "border-outline-variant/10 bg-surface-container-low hover:border-primary/30"
@ -240,7 +230,7 @@ export function ProductVariantShowcase({
<span className="material-symbols-outlined text-outline/30">image</span>
)}
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-black text-on-surface">{model.name || `Model ${index + 1}`}</p>
<p className="mt-1 text-xs font-semibold text-primary">{getModelPriceLabel(model)}</p>
<p className="mt-1 text-[10px] font-bold uppercase tracking-widest text-outline">
@ -282,18 +272,20 @@ export function ProductVariantShowcase({
)}
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-low p-4">
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">{l.selectedDetailTitle}</p>
<DetailRow label={l.modelLabel} value={selectedModel?.name || "-"} />
<DetailRow label={l.sku} value={selectedModel?.sku} />
{selectedMeasurement ? (
<DetailRow label={l.measurementLabel} value={buildMeasurementLabel(selectedMeasurement, selectedMeasurementIndex)} />
) : null}
<DetailRow label={l.price} value={selectedPrice} />
<DetailRow label={l.weight} value={selectedWeight} />
<DetailRow label={l.dimension} value={selectedDimension} />
<DetailRow label={l.promoPrice} value={selectedPromotionPrice} />
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-low p-4">
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">{l.selectedDetailTitle}</p>
<div className="grid grid-cols-1 gap-x-8 sm:grid-cols-2">
<DetailRow label={l.modelLabel} value={selectedModel?.name || "-"} />
<DetailRow label={l.sku} value={selectedModel?.sku} />
{selectedMeasurement ? (
<DetailRow label={l.measurementLabel} value={buildMeasurementLabel(selectedMeasurement, selectedMeasurementIndex)} />
) : null}
<DetailRow label={l.price} value={selectedPrice} />
<DetailRow label={l.weight} value={selectedWeight} />
<DetailRow label={l.dimension} value={selectedDimension} />
<DetailRow label={l.promoPrice} value={selectedPromotionPrice} />
</div>
</div>
{selectedWarehouses.length > 0 ? (
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-low p-4">

25
src/lib/image-url.ts Normal file
View File

@ -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 });
}

View File

@ -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