Fix edit product images and warranty toggle

This commit is contained in:
2026-05-31 10:47:55 +07:00
parent d15bf885e2
commit 2cfff02b69

View File

@ -7,6 +7,7 @@ import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message"; import { getBackendErrorMessage } from "@/lib/error-message";
import { assertValidAddProductPayload } from "@/lib/product-request-validation"; import { assertValidAddProductPayload } from "@/lib/product-request-validation";
import { assertUploadFileSize } from "@/lib/upload-limits"; import { assertUploadFileSize } from "@/lib/upload-limits";
import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -108,6 +109,7 @@ interface EditState {
fileId: string; fileId: string;
}; };
warrantyInformation: { warrantyInformation: {
enabled: boolean;
type: string; type: string;
duration: string; duration: string;
durationType: string; durationType: string;
@ -351,6 +353,9 @@ function newModel(index: number): EditModel {
function apiToEditState(data: ApiProduct): EditState { function apiToEditState(data: ApiProduct): EditState {
const rawModels = Array.isArray(data?.productModels) ? data.productModels : []; const rawModels = Array.isArray(data?.productModels) ? data.productModels : [];
const warrantyType = toStr(data?.warrantyInformation?.type);
const warrantyDuration = toStr(data?.warrantyInformation?.duration);
const hasWarranty = Boolean(warrantyType.trim() || warrantyDuration.trim());
const models: EditModel[] = rawModels.length > 0 const models: EditModel[] = rawModels.length > 0
? rawModels.map((m: ApiModel, i: number) => ({ ? rawModels.map((m: ApiModel, i: number) => ({
@ -452,8 +457,9 @@ function apiToEditState(data: ApiProduct): EditState {
fileId: toStr(data?.complianceInformation?.fileId ?? data?.complianceInformation?.file), fileId: toStr(data?.complianceInformation?.fileId ?? data?.complianceInformation?.file),
}, },
warrantyInformation: { warrantyInformation: {
type: toStr(data?.warrantyInformation?.type), enabled: hasWarranty,
duration: toStr(data?.warrantyInformation?.duration), type: warrantyType,
duration: warrantyDuration,
durationType: toStr(data?.warrantyInformation?.durationType) || "MONTH", durationType: toStr(data?.warrantyInformation?.durationType) || "MONTH",
}, },
}; };
@ -491,10 +497,24 @@ function ImageSlotUpload({
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [displayUrl, setDisplayUrl] = useState("");
const fallbackUrl = fileId && !fileId.startsWith("http")
? resolveBackendImageUrlFromValue(fileId, false)
: "";
useEffect(() => {
setDisplayUrl(previewUrl || resolveBackendImageUrlFromValue(fileId, true));
}, [fileId, previewUrl]);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const objectUrl = URL.createObjectURL(file);
setPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current);
return objectUrl;
});
setUploading(true); setUploading(true);
setError(""); setError("");
try { try {
@ -502,6 +522,8 @@ function ImageSlotUpload({
onUploaded(fileId); onUploaded(fileId);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal"); setError(err instanceof Error ? err.message : "Upload gagal");
URL.revokeObjectURL(objectUrl);
setPreviewUrl("");
} finally { } finally {
setUploading(false); setUploading(false);
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
@ -510,10 +532,25 @@ function ImageSlotUpload({
return ( return (
<div className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10"> <div className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
<div className="w-11 h-11 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0"> <div className="w-11 h-11 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden">
<span className="material-symbols-outlined text-xl" style={{ fontVariationSettings: fileId ? "'FILL' 1" : "'FILL' 0", color: fileId ? "var(--md-sys-color-primary)" : undefined }}> {displayUrl ? (
{fileId ? "image" : "add_photo_alternate"} <img
</span> src={displayUrl}
alt=""
className="w-full h-full object-cover"
onError={() => {
if (fallbackUrl && displayUrl !== fallbackUrl) {
setDisplayUrl(fallbackUrl);
} else {
setDisplayUrl("");
}
}}
/>
) : (
<span className="material-symbols-outlined text-xl" style={{ fontVariationSettings: fileId ? "'FILL' 1" : "'FILL' 0", color: fileId ? "var(--md-sys-color-primary)" : undefined }}>
{fileId ? "image" : "add_photo_alternate"}
</span>
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{label}</p> <p className="text-[10px] font-black uppercase tracking-widest text-outline">{label}</p>
@ -542,12 +579,23 @@ function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (i
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState(""); const [previewUrl, setPreviewUrl] = useState("");
const [displayUrl, setDisplayUrl] = useState("");
const fallbackUrl = value && !value.startsWith("http")
? resolveBackendImageUrlFromValue(value, false)
: "";
useEffect(() => {
setDisplayUrl(previewUrl || resolveBackendImageUrlFromValue(value, true));
}, [previewUrl, value]);
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl); setPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current);
return objectUrl;
});
setUploading(true); setUploading(true);
setError(""); setError("");
try { try {
@ -555,6 +603,7 @@ function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (i
onUploaded(fileId); onUploaded(fileId);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal"); setError(err instanceof Error ? err.message : "Upload gagal");
URL.revokeObjectURL(objectUrl);
setPreviewUrl(""); setPreviewUrl("");
} finally { } finally {
setUploading(false); setUploading(false);
@ -572,8 +621,19 @@ function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (i
{hasImage ? ( {hasImage ? (
<> <>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
{previewUrl ? ( {displayUrl ? (
<img src={previewUrl} alt="Model preview" className="w-full h-full object-cover" /> <img
src={displayUrl}
alt="Model preview"
className="w-full h-full object-cover"
onError={() => {
if (fallbackUrl && displayUrl !== fallbackUrl) {
setDisplayUrl(fallbackUrl);
} else {
setDisplayUrl("");
}
}}
/>
) : ( ) : (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span className="material-symbols-outlined text-4xl text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>image</span> <span className="material-symbols-outlined text-4xl text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
@ -1343,7 +1403,17 @@ function EditProductPageInner() {
productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue), productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue),
categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue), categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue),
complianceInformation: { ...form.complianceInformation }, complianceInformation: { ...form.complianceInformation },
warrantyInformation: { ...form.warrantyInformation, duration: toNum(form.warrantyInformation.duration) }, warrantyInformation: form.warrantyInformation.enabled
? {
type: form.warrantyInformation.type,
duration: toNum(form.warrantyInformation.duration),
durationType: form.warrantyInformation.durationType,
}
: {
type: null,
duration: null,
durationType: "MONTH",
},
state: resolvedState, state: resolvedState,
}; };
return base; return base;
@ -1844,22 +1914,43 @@ function EditProductPageInner() {
{/* Warranty */} {/* Warranty */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-black text-on-surface">{e.warranty}</h3> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-black text-on-surface">{e.warranty}</h3>
<label className="inline-flex items-center gap-3 text-xs font-black uppercase tracking-[0.16em] text-on-surface-variant">
<span>{form.warrantyInformation.enabled ? "On" : "Off"}</span>
<input
type="checkbox"
checked={form.warrantyInformation.enabled}
onChange={(ev) =>
update({
warrantyInformation: {
...form.warrantyInformation,
enabled: ev.target.checked,
},
})
}
className="peer sr-only"
/>
<span className="relative h-7 w-12 rounded-full bg-surface-container-high transition-colors peer-checked:bg-primary after:absolute after:left-1 after:top-1 after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-transform peer-checked:after:translate-x-5" />
</label>
</div>
<div className={form.warrantyInformation.enabled ? "space-y-4" : "space-y-4 opacity-50"}>
<input value={form.warrantyInformation.type} <input value={form.warrantyInformation.type}
onChange={(ev) => update({ warrantyInformation: { ...form.warrantyInformation, type: ev.target.value } })} onChange={(ev) => update({ warrantyInformation: { ...form.warrantyInformation, type: ev.target.value } })}
placeholder={e.warrantyType} className={inputCls} /> placeholder={e.warrantyType} disabled={!form.warrantyInformation.enabled} className={inputCls} />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<input value={form.warrantyInformation.duration} <input value={form.warrantyInformation.duration}
onChange={(ev) => update({ warrantyInformation: { ...form.warrantyInformation, duration: ev.target.value } })} onChange={(ev) => update({ warrantyInformation: { ...form.warrantyInformation, duration: ev.target.value } })}
placeholder={e.warrantyDuration} type="number" min="0" className={inputCls} /> placeholder={e.warrantyDuration} type="number" min="0" disabled={!form.warrantyInformation.enabled} className={inputCls} />
<select value={form.warrantyInformation.durationType} <select value={form.warrantyInformation.durationType}
onChange={(ev) => update({ warrantyInformation: { ...form.warrantyInformation, durationType: ev.target.value } })} onChange={(ev) => update({ warrantyInformation: { ...form.warrantyInformation, durationType: ev.target.value } })}
className={inputCls}> disabled={!form.warrantyInformation.enabled} className={inputCls}>
<option value="DAY">DAY</option> <option value="DAY">DAY</option>
<option value="MONTH">MONTH</option> <option value="MONTH">MONTH</option>
<option value="YEAR">YEAR</option> <option value="YEAR">YEAR</option>
</select> </select>
</div> </div>
</div>
</div> </div>
<div className="border-t border-surface-container" /> <div className="border-t border-surface-container" />