Refine admin review and product image handling

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

View File

@ -5,8 +5,7 @@ import { useParams, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
import { useLanguage } from "@/lib/i18n-context";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
import { resolveBackendImageUrl } from "@/lib/image-url";
interface ProductWarehouse {
id?: string;
@ -41,6 +40,7 @@ interface ProductModel {
name?: string;
sku?: string;
imageId?: string;
image?: string;
price?: string | number;
currency?: string;
weight?: string | number;
@ -59,6 +59,7 @@ interface ProductModel {
interface ProductImage {
sequence?: number;
imageId?: string;
image?: string;
}
interface ProductInfoItem {
@ -91,6 +92,7 @@ interface ProductDetail {
preOrderDay?: string | number;
description?: string;
imageId?: string;
image?: string;
productImages?: ProductImage[];
productModels?: ProductModel[];
productKeyWords?: string[];
@ -136,8 +138,12 @@ function Row({ label, value }: { label: string; value?: string | number | boolea
);
}
function isNonEmptyString(value: string | undefined): value is string {
return typeof value === "string" && value.length > 0;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function resolveImageUrl(imageId?: string | null, image?: string | null) {
return resolveBackendImageUrl({ image, imageId });
}
function ToggleBadge({ label, value }: { label: string; value: boolean }) {
@ -298,16 +304,21 @@ function ProductDetailPageInner() {
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
const productInfos = Array.isArray(product.productInformations) ? product.productInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
const categoryInfos = Array.isArray(product.categoryInformations) ? product.categoryInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
const allImages: string[] = [
...(product.imageId ? [product.imageId] : []),
const allImages: Array<{ id?: string; url: string }> = [
...(product.imageId || product.image
? [{ id: product.imageId, url: resolveImageUrl(product.imageId, product.image) }]
: []),
...(Array.isArray(product.productImages)
? product.productImages
.sort(
(a: ProductImage, b: ProductImage) =>
(a.sequence ?? 0) - (b.sequence ?? 0)
)
.map((img: ProductImage) => img.imageId)
.filter(isNonEmptyString)
.map((img: ProductImage) => ({
id: img.imageId,
url: resolveImageUrl(img.imageId, img.image),
}))
.filter((img) => isNonEmptyString(img.url))
: []),
];
const isReviewProduct = isReview || product.state === "REVIEW";
@ -423,12 +434,12 @@ function ProductDetailPageInner() {
</div>
) : (
<div className="space-y-3">
{allImages.map((imgId, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
{allImages.map((img, i) => (
<div key={`${img.id || img.url}-${i}`} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
<div className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${API_BASE}/api/v1.0/file/image/${imgId}`}
src={img.url}
alt={i === 0 ? "Main Image" : `Gallery ${i}`}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}

View File

@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit";
@ -30,6 +30,52 @@ function ImageSlotItem({
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [persistedPreviewUrl, setPersistedPreviewUrl] = useState("");
useEffect(() => {
let objectUrl = "";
let cancelled = false;
async function loadPersistedPreview() {
if (!fileId || previewUrl || fileId.startsWith("http")) {
setPersistedPreviewUrl(fileId.startsWith("http") ? fileId : "");
return;
}
const candidates = [
`/api/file/image/${encodeURIComponent(fileId)}`,
`/api/file/image/tmp/${encodeURIComponent(fileId)}`,
];
for (const url of candidates) {
try {
const res = await fetch(url, {
headers: { "x-auth-token": getToken() },
cache: "no-store",
});
if (!res.ok) continue;
const blob = await res.blob();
if (!blob.type.startsWith("image/")) continue;
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setPersistedPreviewUrl(objectUrl);
return;
} catch {
// Try the next possible image path.
}
}
if (!cancelled) setPersistedPreviewUrl("");
}
loadPersistedPreview();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileId, previewUrl]);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@ -62,7 +108,8 @@ function ImageSlotItem({
}
}
const hasImage = fileId || previewUrl;
const displayPreviewUrl = previewUrl || persistedPreviewUrl;
const hasImage = fileId || displayPreviewUrl;
return (
<div className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
@ -70,8 +117,8 @@ function ImageSlotItem({
className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden cursor-pointer"
onClick={() => inputRef.current?.click()}
>
{previewUrl ? (
<img src={previewUrl} alt="" className="w-full h-full object-cover" />
{displayPreviewUrl ? (
<img src={displayPreviewUrl} alt="" className="w-full h-full object-cover" />
) : (
<span
className="material-symbols-outlined text-2xl"

View File

@ -91,6 +91,52 @@ function ModelImageUpload({
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [persistedPreviewUrl, setPersistedPreviewUrl] = useState("");
useEffect(() => {
let objectUrl = "";
let cancelled = false;
async function loadPersistedPreview() {
if (!value || previewUrl || value.startsWith("http")) {
setPersistedPreviewUrl(value.startsWith("http") ? value : "");
return;
}
const candidates = [
`/api/file/image/${encodeURIComponent(value)}`,
`/api/file/image/tmp/${encodeURIComponent(value)}`,
];
for (const url of candidates) {
try {
const res = await fetch(url, {
headers: { "x-auth-token": getToken() },
cache: "no-store",
});
if (!res.ok) continue;
const blob = await res.blob();
if (!blob.type.startsWith("image/")) continue;
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setPersistedPreviewUrl(objectUrl);
return;
} catch {
// Try the next possible image path.
}
}
if (!cancelled) setPersistedPreviewUrl("");
}
loadPersistedPreview();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [value, previewUrl]);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@ -123,7 +169,8 @@ function ModelImageUpload({
}
}
const hasImage = value || previewUrl;
const displayPreviewUrl = previewUrl || persistedPreviewUrl;
const hasImage = value || displayPreviewUrl;
return (
<div
@ -133,8 +180,8 @@ function ModelImageUpload({
{hasImage ? (
<>
<div className="absolute inset-0 flex items-center justify-center">
{previewUrl ? (
<img src={previewUrl} alt="Model preview" className="w-full h-full object-cover" />
{displayPreviewUrl ? (
<img src={displayPreviewUrl} alt="Model preview" className="w-full h-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2">
<span

View File

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