Refine admin review and product image handling
This commit is contained in:
@ -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,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">
|
||||
|
||||
Reference in New Issue
Block a user