Refine admin review and product image handling
This commit is contained in:
154
HANDOFF.md
154
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`
|
||||
|
||||
@ -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"; }}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,16 +279,42 @@ 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="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>
|
||||
|
||||
<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-sm font-black text-on-surface">{model.name || `Model ${idx + 1}`}</p>
|
||||
<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-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={
|
||||
@ -259,10 +348,12 @@ export default function ProductReviewPage() {
|
||||
<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">
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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,7 +309,20 @@ function AdminProductsPageInner() {
|
||||
return (
|
||||
<tr key={product.id} className="group hover:bg-surface-container-low transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div>
|
||||
<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"
|
||||
@ -318,6 +332,7 @@ function AdminProductsPageInner() {
|
||||
</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">
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-wider ${marketBadge(product.market)} ${isInactiveInAllTab ? "line-through opacity-60" : ""}`}>
|
||||
|
||||
@ -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,8 +348,13 @@ 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(
|
||||
@ -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">
|
||||
{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 */}
|
||||
{shouldRender("details") ? (
|
||||
<SectionCard
|
||||
title="Detail Produk"
|
||||
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>
|
||||
) : 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 (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;
|
||||
}
|
||||
|
||||
if (shouldCompare) {
|
||||
const originalRes = await fetch(`/api/admin/review/${reviewId}/original`, {
|
||||
const reviewRes = await fetch(`/api/admin/review/${params.productId}`, {
|
||||
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);
|
||||
}
|
||||
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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/api/file/image/[...path]/route.ts
Normal file
35
src/app/api/file/image/[...path]/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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">
|
||||
@ -284,6 +274,7 @@ export function ProductVariantShowcase({
|
||||
|
||||
<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 ? (
|
||||
@ -294,6 +285,7 @@ export function ProductVariantShowcase({
|
||||
<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
25
src/lib/image-url.ts
Normal 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 });
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user