Fix edit product images and warranty toggle
This commit is contained in:
@ -7,6 +7,7 @@ import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
import { assertValidAddProductPayload } from "@/lib/product-request-validation";
|
||||
import { assertUploadFileSize } from "@/lib/upload-limits";
|
||||
import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -108,6 +109,7 @@ interface EditState {
|
||||
fileId: string;
|
||||
};
|
||||
warrantyInformation: {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
duration: string;
|
||||
durationType: string;
|
||||
@ -351,6 +353,9 @@ function newModel(index: number): EditModel {
|
||||
|
||||
function apiToEditState(data: ApiProduct): EditState {
|
||||
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
|
||||
? rawModels.map((m: ApiModel, i: number) => ({
|
||||
@ -452,8 +457,9 @@ function apiToEditState(data: ApiProduct): EditState {
|
||||
fileId: toStr(data?.complianceInformation?.fileId ?? data?.complianceInformation?.file),
|
||||
},
|
||||
warrantyInformation: {
|
||||
type: toStr(data?.warrantyInformation?.type),
|
||||
duration: toStr(data?.warrantyInformation?.duration),
|
||||
enabled: hasWarranty,
|
||||
type: warrantyType,
|
||||
duration: warrantyDuration,
|
||||
durationType: toStr(data?.warrantyInformation?.durationType) || "MONTH",
|
||||
},
|
||||
};
|
||||
@ -491,10 +497,24 @@ function ImageSlotUpload({
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
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>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl((current) => {
|
||||
if (current) URL.revokeObjectURL(current);
|
||||
return objectUrl;
|
||||
});
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
@ -502,6 +522,8 @@ function ImageSlotUpload({
|
||||
onUploaded(fileId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setPreviewUrl("");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
@ -510,10 +532,25 @@ function ImageSlotUpload({
|
||||
|
||||
return (
|
||||
<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">
|
||||
{displayUrl ? (
|
||||
<img
|
||||
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 className="flex-1 min-w-0">
|
||||
<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 [error, setError] = 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>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl(objectUrl);
|
||||
setPreviewUrl((current) => {
|
||||
if (current) URL.revokeObjectURL(current);
|
||||
return objectUrl;
|
||||
});
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
@ -555,6 +603,7 @@ function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (i
|
||||
onUploaded(fileId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setPreviewUrl("");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@ -572,8 +621,19 @@ function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (i
|
||||
{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" />
|
||||
{displayUrl ? (
|
||||
<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">
|
||||
<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),
|
||||
categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue),
|
||||
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,
|
||||
};
|
||||
return base;
|
||||
@ -1844,23 +1914,44 @@ function EditProductPageInner() {
|
||||
|
||||
{/* Warranty */}
|
||||
<div className="space-y-4">
|
||||
<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}
|
||||
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">
|
||||
<input value={form.warrantyInformation.duration}
|
||||
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}
|
||||
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="MONTH">MONTH</option>
|
||||
<option value="YEAR">YEAR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-surface-container" />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user