feat: update content popup
continuous-integration/drone/push Build is failing Details

This commit is contained in:
hanif salafi 2026-04-10 15:18:11 +07:00
parent 2eaa34d052
commit 6176567557
4 changed files with 479 additions and 124 deletions

View File

@ -1,6 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import {
useCallback,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -40,27 +46,47 @@ import {
getPartnerContents, getPartnerContents,
getPopupNewsList, getPopupNewsList,
saveAboutContent, saveAboutContent,
saveAboutUsMediaUrl, saveAboutUsMediaUpload,
saveHeroContent, saveHeroContent,
saveHeroImage, saveHeroImageUpload,
saveOurProductContent, saveOurProductContent,
saveOurServiceContent, saveOurServiceContent,
savePartnerContent, savePartnerContent,
saveOurProductImage, saveOurProductImageUpload,
saveOurServiceImage, saveOurServiceImageUpload,
savePopupNews, savePopupNews,
savePopupNewsImage, savePopupNewsImageUpload,
updateAboutContent, updateAboutContent,
updateHeroContent, updateHeroContent,
updateHeroImage, updateHeroImageUpload,
updateOurProductContent, updateOurProductContent,
updateOurProductImage, updateOurProductImageUpload,
updateOurServiceContent, updateOurServiceContent,
updateOurServiceImage, updateOurServiceImageUpload,
updatePartnerContent, updatePartnerContent,
updatePopupNews, updatePopupNews,
uploadPartnerLogo,
} from "@/service/cms-landing"; } from "@/service/cms-landing";
function revokeBlobRef(ref: MutableRefObject<string | null>) {
if (ref.current) {
URL.revokeObjectURL(ref.current);
ref.current = null;
}
}
function setPickedFile(
file: File | null,
ref: MutableRefObject<string | null>,
setter: (f: File | null) => void,
) {
revokeBlobRef(ref);
if (file) {
ref.current = URL.createObjectURL(file);
}
setter(file);
}
export default function ContentWebsite() { export default function ContentWebsite() {
const [activeTab, setActiveTab] = useState("hero"); const [activeTab, setActiveTab] = useState("hero");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -73,7 +99,9 @@ export default function ContentWebsite() {
const [heroDesc, setHeroDesc] = useState(""); const [heroDesc, setHeroDesc] = useState("");
const [heroCta1, setHeroCta1] = useState(""); const [heroCta1, setHeroCta1] = useState("");
const [heroCta2, setHeroCta2] = useState(""); const [heroCta2, setHeroCta2] = useState("");
const [heroImgUrl, setHeroImgUrl] = useState(""); const [heroRemoteUrl, setHeroRemoteUrl] = useState("");
const [heroPendingFile, setHeroPendingFile] = useState<File | null>(null);
const heroBlobUrlRef = useRef<string | null>(null);
const [aboutId, setAboutId] = useState<number | null>(null); const [aboutId, setAboutId] = useState<number | null>(null);
const [aboutPrimary, setAboutPrimary] = useState(""); const [aboutPrimary, setAboutPrimary] = useState("");
@ -81,18 +109,23 @@ export default function ContentWebsite() {
const [aboutDesc, setAboutDesc] = useState(""); const [aboutDesc, setAboutDesc] = useState("");
const [aboutCta1, setAboutCta1] = useState(""); const [aboutCta1, setAboutCta1] = useState("");
const [aboutCta2, setAboutCta2] = useState(""); const [aboutCta2, setAboutCta2] = useState("");
const [aboutMediaUrl, setAboutMediaUrl] = useState(""); const [aboutRemoteMediaUrl, setAboutRemoteMediaUrl] = useState("");
const [aboutPendingFile, setAboutPendingFile] = useState<File | null>(null);
const aboutBlobUrlRef = useRef<string | null>(null);
const [aboutMediaImageId, setAboutMediaImageId] = useState<number | null>( const [aboutMediaImageId, setAboutMediaImageId] = useState<number | null>(
null, null,
); );
const [aboutMediaLoadedUrl, setAboutMediaLoadedUrl] = useState("");
const [products, setProducts] = useState<CmsProductContent[]>([]); const [products, setProducts] = useState<CmsProductContent[]>([]);
const [productEditId, setProductEditId] = useState<string | null>(null); const [productEditId, setProductEditId] = useState<string | null>(null);
const [productPrimary, setProductPrimary] = useState(""); const [productPrimary, setProductPrimary] = useState("");
const [productSecondary, setProductSecondary] = useState(""); const [productSecondary, setProductSecondary] = useState("");
const [productDesc, setProductDesc] = useState(""); const [productDesc, setProductDesc] = useState("");
const [productImgUrl, setProductImgUrl] = useState(""); const [productRemoteUrl, setProductRemoteUrl] = useState("");
const [productPendingFile, setProductPendingFile] = useState<File | null>(
null,
);
const productBlobUrlRef = useRef<string | null>(null);
const [productImageId, setProductImageId] = useState<string | null>(null); const [productImageId, setProductImageId] = useState<string | null>(null);
const [productModalOpen, setProductModalOpen] = useState(false); const [productModalOpen, setProductModalOpen] = useState(false);
@ -101,13 +134,22 @@ export default function ContentWebsite() {
const [servicePrimary, setServicePrimary] = useState(""); const [servicePrimary, setServicePrimary] = useState("");
const [serviceSecondary, setServiceSecondary] = useState(""); const [serviceSecondary, setServiceSecondary] = useState("");
const [serviceDesc, setServiceDesc] = useState(""); const [serviceDesc, setServiceDesc] = useState("");
const [serviceImgUrl, setServiceImgUrl] = useState(""); const [serviceRemoteUrl, setServiceRemoteUrl] = useState("");
const [servicePendingFile, setServicePendingFile] = useState<File | null>(
null,
);
const serviceBlobUrlRef = useRef<string | null>(null);
const [serviceImageId, setServiceImageId] = useState<string | null>(null); const [serviceImageId, setServiceImageId] = useState<string | null>(null);
const [serviceModalOpen, setServiceModalOpen] = useState(false); const [serviceModalOpen, setServiceModalOpen] = useState(false);
const [partners, setPartners] = useState<CmsPartnerContent[]>([]); const [partners, setPartners] = useState<CmsPartnerContent[]>([]);
const [partnerTitle, setPartnerTitle] = useState(""); const [partnerTitle, setPartnerTitle] = useState("");
const [partnerImgUrl, setPartnerImgUrl] = useState(""); const [partnerRemoteUrl, setPartnerRemoteUrl] = useState("");
const [partnerStoredPath, setPartnerStoredPath] = useState("");
const [partnerPendingFile, setPartnerPendingFile] = useState<File | null>(
null,
);
const partnerBlobUrlRef = useRef<string | null>(null);
const [editingPartnerId, setEditingPartnerId] = useState<string | null>(null); const [editingPartnerId, setEditingPartnerId] = useState<string | null>(null);
const [partnerModalOpen, setPartnerModalOpen] = useState(false); const [partnerModalOpen, setPartnerModalOpen] = useState(false);
@ -118,7 +160,9 @@ export default function ContentWebsite() {
const [popupDesc, setPopupDesc] = useState(""); const [popupDesc, setPopupDesc] = useState("");
const [popupCta1, setPopupCta1] = useState(""); const [popupCta1, setPopupCta1] = useState("");
const [popupCta2, setPopupCta2] = useState(""); const [popupCta2, setPopupCta2] = useState("");
const [popupImgUrl, setPopupImgUrl] = useState(""); const [popupRemoteUrl, setPopupRemoteUrl] = useState("");
const [popupPendingFile, setPopupPendingFile] = useState<File | null>(null);
const popupBlobUrlRef = useRef<string | null>(null);
const [popupModalOpen, setPopupModalOpen] = useState(false); const [popupModalOpen, setPopupModalOpen] = useState(false);
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
@ -134,11 +178,13 @@ export default function ContentWebsite() {
setHeroCta1(hero.primary_cta ?? ""); setHeroCta1(hero.primary_cta ?? "");
setHeroCta2(hero.secondary_cta_text ?? ""); setHeroCta2(hero.secondary_cta_text ?? "");
const first = hero.images?.[0]; const first = hero.images?.[0];
revokeBlobRef(heroBlobUrlRef);
setHeroPendingFile(null);
if (first?.image_url) { if (first?.image_url) {
setHeroImgUrl(first.image_url); setHeroRemoteUrl(first.image_url);
setHeroImageId(first.id ?? null); setHeroImageId(first.id ?? null);
} else { } else {
setHeroImgUrl(""); setHeroRemoteUrl("");
setHeroImageId(null); setHeroImageId(null);
} }
} else { } else {
@ -149,7 +195,9 @@ export default function ContentWebsite() {
setHeroDesc(""); setHeroDesc("");
setHeroCta1(""); setHeroCta1("");
setHeroCta2(""); setHeroCta2("");
setHeroImgUrl(""); revokeBlobRef(heroBlobUrlRef);
setHeroPendingFile(null);
setHeroRemoteUrl("");
} }
const aboutRes = await getAboutContentsList(); const aboutRes = await getAboutContentsList();
@ -164,8 +212,9 @@ export default function ContentWebsite() {
setAboutCta2(ab.secondary_cta_text ?? ""); setAboutCta2(ab.secondary_cta_text ?? "");
const am = ab.images?.[0]; const am = ab.images?.[0];
const murl = am?.media_url?.trim() ?? ""; const murl = am?.media_url?.trim() ?? "";
setAboutMediaUrl(murl); revokeBlobRef(aboutBlobUrlRef);
setAboutMediaLoadedUrl(murl); setAboutPendingFile(null);
setAboutRemoteMediaUrl(murl);
setAboutMediaImageId(am?.id ?? null); setAboutMediaImageId(am?.id ?? null);
} else { } else {
setAboutId(null); setAboutId(null);
@ -174,8 +223,9 @@ export default function ContentWebsite() {
setAboutDesc(""); setAboutDesc("");
setAboutCta1(""); setAboutCta1("");
setAboutCta2(""); setAboutCta2("");
setAboutMediaUrl(""); revokeBlobRef(aboutBlobUrlRef);
setAboutMediaLoadedUrl(""); setAboutPendingFile(null);
setAboutRemoteMediaUrl("");
setAboutMediaImageId(null); setAboutMediaImageId(null);
} }
@ -231,15 +281,26 @@ export default function ContentWebsite() {
return; return;
} }
} }
if (heroImgUrl.trim() && hid) { if (heroPendingFile && hid) {
const fd = new FormData();
fd.append("file", heroPendingFile);
let imgRes;
if (heroImageId) { if (heroImageId) {
await updateHeroImage(heroImageId, { image_url: heroImgUrl.trim() }); imgRes = await updateHeroImageUpload(heroImageId, fd);
} else { } else {
await saveHeroImage({ fd.append("hero_content_id", hid);
hero_content_id: hid, imgRes = await saveHeroImageUpload(fd);
image_url: heroImgUrl.trim(),
});
} }
if (imgRes?.error) {
await Swal.fire({
icon: "error",
title: "Hero image upload failed",
text: String(imgRes.message ?? ""),
});
return;
}
revokeBlobRef(heroBlobUrlRef);
setHeroPendingFile(null);
} }
await Swal.fire({ icon: "success", title: "Hero section saved", timer: 1600, showConfirmButton: false }); await Swal.fire({ icon: "success", title: "Hero section saved", timer: 1600, showConfirmButton: false });
await loadAll(); await loadAll();
@ -280,29 +341,24 @@ export default function ContentWebsite() {
} }
} }
const url = aboutMediaUrl.trim(); if (aid != null && aboutPendingFile) {
if (aid != null) {
if (!url) {
if (aboutMediaImageId != null) { if (aboutMediaImageId != null) {
await deleteAboutUsContentImage(aboutMediaImageId); await deleteAboutUsContentImage(aboutMediaImageId);
} }
} else if (url !== aboutMediaLoadedUrl || aboutMediaImageId == null) { const fd = new FormData();
if (aboutMediaImageId != null) { fd.append("about_us_content_id", String(aid));
await deleteAboutUsContentImage(aboutMediaImageId); fd.append("file", aboutPendingFile);
} const mres = await saveAboutUsMediaUpload(fd);
const mres = await saveAboutUsMediaUrl({
about_us_content_id: aid,
media_url: url,
});
if (mres?.error) { if (mres?.error) {
await Swal.fire({ await Swal.fire({
icon: "error", icon: "error",
title: "Media URL failed", title: "Media upload failed",
text: String(mres.message ?? ""), text: String(mres.message ?? ""),
}); });
return; return;
} }
} revokeBlobRef(aboutBlobUrlRef);
setAboutPendingFile(null);
} }
await Swal.fire({ icon: "success", title: "About Us saved", timer: 1600, showConfirmButton: false }); await Swal.fire({ icon: "success", title: "About Us saved", timer: 1600, showConfirmButton: false });
@ -318,7 +374,9 @@ export default function ContentWebsite() {
setProductPrimary(""); setProductPrimary("");
setProductSecondary(""); setProductSecondary("");
setProductDesc(""); setProductDesc("");
setProductImgUrl(""); revokeBlobRef(productBlobUrlRef);
setProductPendingFile(null);
setProductRemoteUrl("");
setProductImageId(null); setProductImageId(null);
return; return;
} }
@ -327,7 +385,9 @@ export default function ContentWebsite() {
setProductSecondary(p.secondary_title ?? ""); setProductSecondary(p.secondary_title ?? "");
setProductDesc(p.description ?? ""); setProductDesc(p.description ?? "");
const im = p.images?.[0]; const im = p.images?.[0];
setProductImgUrl(im?.image_url?.trim() ?? ""); revokeBlobRef(productBlobUrlRef);
setProductPendingFile(null);
setProductRemoteUrl(im?.image_url?.trim() ?? "");
setProductImageId(im?.id ?? null); setProductImageId(im?.id ?? null);
} }
@ -369,15 +429,26 @@ export default function ContentWebsite() {
return; return;
} }
} }
if (productImgUrl.trim() && pid) { if (productPendingFile && pid) {
const fd = new FormData();
fd.append("file", productPendingFile);
let ires;
if (productImageId) { if (productImageId) {
await updateOurProductImage(productImageId, { image_url: productImgUrl.trim() }); ires = await updateOurProductImageUpload(productImageId, fd);
} else { } else {
await saveOurProductImage({ fd.append("our_product_content_id", pid);
our_product_content_id: pid, ires = await saveOurProductImageUpload(fd);
image_url: productImgUrl.trim(),
});
} }
if (ires?.error) {
await Swal.fire({
icon: "error",
title: "Product image upload failed",
text: String(ires.message ?? ""),
});
return;
}
revokeBlobRef(productBlobUrlRef);
setProductPendingFile(null);
} }
await Swal.fire({ icon: "success", title: "Product saved", timer: 1400, showConfirmButton: false }); await Swal.fire({ icon: "success", title: "Product saved", timer: 1400, showConfirmButton: false });
await loadAll(); await loadAll();
@ -414,7 +485,9 @@ export default function ContentWebsite() {
setServicePrimary(""); setServicePrimary("");
setServiceSecondary(""); setServiceSecondary("");
setServiceDesc(""); setServiceDesc("");
setServiceImgUrl(""); revokeBlobRef(serviceBlobUrlRef);
setServicePendingFile(null);
setServiceRemoteUrl("");
setServiceImageId(null); setServiceImageId(null);
return; return;
} }
@ -423,7 +496,9 @@ export default function ContentWebsite() {
setServiceSecondary(s.secondary_title ?? ""); setServiceSecondary(s.secondary_title ?? "");
setServiceDesc(s.description ?? ""); setServiceDesc(s.description ?? "");
const im = s.images?.[0]; const im = s.images?.[0];
setServiceImgUrl(im?.image_url?.trim() ?? ""); revokeBlobRef(serviceBlobUrlRef);
setServicePendingFile(null);
setServiceRemoteUrl(im?.image_url?.trim() ?? "");
setServiceImageId(im?.id != null ? String(im.id) : null); setServiceImageId(im?.id != null ? String(im.id) : null);
} }
@ -465,15 +540,26 @@ export default function ContentWebsite() {
return; return;
} }
} }
if (serviceImgUrl.trim() && sid != null) { if (servicePendingFile && sid != null) {
const fd = new FormData();
fd.append("file", servicePendingFile);
let ires;
if (serviceImageId) { if (serviceImageId) {
await updateOurServiceImage(serviceImageId, { image_url: serviceImgUrl.trim() }); ires = await updateOurServiceImageUpload(serviceImageId, fd);
} else { } else {
await saveOurServiceImage({ fd.append("our_service_content_id", String(sid));
our_service_content_id: sid, ires = await saveOurServiceImageUpload(fd);
image_url: serviceImgUrl.trim(),
});
} }
if (ires?.error) {
await Swal.fire({
icon: "error",
title: "Service image upload failed",
text: String(ires.message ?? ""),
});
return;
}
revokeBlobRef(serviceBlobUrlRef);
setServicePendingFile(null);
} }
await Swal.fire({ icon: "success", title: "Service saved", timer: 1400, showConfirmButton: false }); await Swal.fire({ icon: "success", title: "Service saved", timer: 1400, showConfirmButton: false });
await loadAll(); await loadAll();
@ -508,12 +594,18 @@ export default function ContentWebsite() {
if (!p) { if (!p) {
setEditingPartnerId(null); setEditingPartnerId(null);
setPartnerTitle(""); setPartnerTitle("");
setPartnerImgUrl(""); revokeBlobRef(partnerBlobUrlRef);
setPartnerPendingFile(null);
setPartnerRemoteUrl("");
setPartnerStoredPath("");
return; return;
} }
setEditingPartnerId(p.id); setEditingPartnerId(p.id);
setPartnerTitle(p.primary_title ?? ""); setPartnerTitle(p.primary_title ?? "");
setPartnerImgUrl(p.image_url ?? ""); revokeBlobRef(partnerBlobUrlRef);
setPartnerPendingFile(null);
setPartnerRemoteUrl(p.image_url ?? "");
setPartnerStoredPath(p.image_path ?? "");
} }
function openPartnerModalCreate() { function openPartnerModalCreate() {
@ -535,10 +627,14 @@ export default function ContentWebsite() {
try { try {
const body = { const body = {
primary_title: partnerTitle.trim(), primary_title: partnerTitle.trim(),
image_url: partnerImgUrl.trim() || undefined,
}; };
let partnerId = editingPartnerId;
if (editingPartnerId) { if (editingPartnerId) {
const res = await updatePartnerContent(editingPartnerId, body); const res = await updatePartnerContent(editingPartnerId, {
primary_title: body.primary_title,
image_path: partnerStoredPath,
image_url: partnerRemoteUrl,
});
if (res?.error) { if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") }); await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return; return;
@ -549,6 +645,23 @@ export default function ContentWebsite() {
await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") }); await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") });
return; return;
} }
const created = apiPayload(res) as CmsPartnerContent | null;
partnerId = created?.id ?? null;
}
if (partnerPendingFile && partnerId) {
const fd = new FormData();
fd.append("file", partnerPendingFile);
const up = await uploadPartnerLogo(partnerId, fd);
if (up?.error) {
await Swal.fire({
icon: "error",
title: "Logo upload failed",
text: String(up.message ?? ""),
});
return;
}
revokeBlobRef(partnerBlobUrlRef);
setPartnerPendingFile(null);
} }
beginEditPartner(null); beginEditPartner(null);
setPartnerModalOpen(false); setPartnerModalOpen(false);
@ -587,7 +700,9 @@ export default function ContentWebsite() {
setPopupDesc(""); setPopupDesc("");
setPopupCta1(""); setPopupCta1("");
setPopupCta2(""); setPopupCta2("");
setPopupImgUrl(""); revokeBlobRef(popupBlobUrlRef);
setPopupPendingFile(null);
setPopupRemoteUrl("");
return; return;
} }
setPopupEditId(p.id); setPopupEditId(p.id);
@ -596,7 +711,9 @@ export default function ContentWebsite() {
setPopupDesc(p.description ?? ""); setPopupDesc(p.description ?? "");
setPopupCta1(p.primary_cta ?? ""); setPopupCta1(p.primary_cta ?? "");
setPopupCta2(p.secondary_cta_text ?? ""); setPopupCta2(p.secondary_cta_text ?? "");
setPopupImgUrl(p.images?.[0]?.media_url?.trim() ?? ""); revokeBlobRef(popupBlobUrlRef);
setPopupPendingFile(null);
setPopupRemoteUrl(p.images?.[0]?.media_url?.trim() ?? "");
} }
function openPopupModalCreate() { function openPopupModalCreate() {
@ -630,10 +747,16 @@ export default function ContentWebsite() {
await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") }); await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") });
return; return;
} }
const listRes = await getPopupNewsList(1, 50); const created = apiPayload(res) as CmsPopupContent | null;
const rows = apiRows(listRes) as CmsPopupContent[]; pid = created?.id ?? null;
pid = if (pid == null) {
rows.length > 0 ? Math.max(...rows.map((r) => r.id)) : null; await Swal.fire({
icon: "error",
title: "Save failed",
text: "Could not read new pop-up id from server.",
});
return;
}
} else { } else {
const res = await updatePopupNews(pid, { id: pid, ...body }); const res = await updatePopupNews(pid, { id: pid, ...body });
if (res?.error) { if (res?.error) {
@ -641,11 +764,21 @@ export default function ContentWebsite() {
return; return;
} }
} }
if (popupImgUrl.trim() && pid != null) { if (popupPendingFile && pid != null) {
await savePopupNewsImage({ const fd = new FormData();
popup_news_content_id: pid, fd.append("popup_news_content_id", String(pid));
media_url: popupImgUrl.trim(), fd.append("file", popupPendingFile);
const imgRes = await savePopupNewsImageUpload(fd);
if (imgRes?.error) {
await Swal.fire({
icon: "error",
title: "Popup image upload failed",
text: String(imgRes.message ?? ""),
}); });
return;
}
revokeBlobRef(popupBlobUrlRef);
setPopupPendingFile(null);
} }
await Swal.fire({ icon: "success", title: "Pop up saved", timer: 1600, showConfirmButton: false }); await Swal.fire({ icon: "success", title: "Pop up saved", timer: 1600, showConfirmButton: false });
await loadAll(); await loadAll();
@ -764,25 +897,33 @@ export default function ContentWebsite() {
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-slate-700">Hero image URL</label> <label className="text-sm font-medium text-slate-700">Hero image</label>
<p className="mb-2 text-xs text-slate-500"> <p className="mb-2 text-xs text-slate-500">
Paste a public image URL (CDN / MinIO). Shown on the landing hero. Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero.
</p> </p>
<Input <Input
className="mt-1" className="mt-1 cursor-pointer"
value={heroImgUrl} type="file"
onChange={(e) => setHeroImgUrl(e.target.value)} accept="image/jpeg,image/png,image/gif,image/webp"
placeholder="https://..." onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, heroBlobUrlRef, setHeroPendingFile);
e.target.value = "";
}}
/> />
{heroImgUrl ? ( {heroBlobUrlRef.current || heroRemoteUrl ? (
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6"> <div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={heroImgUrl} alt="" className="max-h-48 object-contain" /> <img
src={heroBlobUrlRef.current || heroRemoteUrl}
alt=""
className="max-h-48 object-contain"
/>
</div> </div>
) : ( ) : (
<div className="mt-4 flex flex-col items-center justify-center rounded-xl border-2 border-dashed bg-slate-50 p-10 text-center"> <div className="mt-4 flex flex-col items-center justify-center rounded-xl border-2 border-dashed bg-slate-50 p-10 text-center">
<ImageIcon className="mb-2 h-8 w-8 text-slate-400" /> <ImageIcon className="mb-2 h-8 w-8 text-slate-400" />
<p className="text-sm text-slate-500">No image URL yet</p> <p className="text-sm text-slate-500">No hero image yet</p>
</div> </div>
)} )}
</div> </div>
@ -843,23 +984,27 @@ export default function ContentWebsite() {
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-slate-700">Media URL</label> <label className="text-sm font-medium text-slate-700">Media (image or video)</label>
<p className="mb-2 text-xs text-slate-500"> <p className="mb-2 text-xs text-slate-500">
Image or video URL (e.g. .mp4 on CDN). Shown inside the phone mockup on the landing page. Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page.
</p> </p>
<Input <Input
className="mt-1" className="mt-1 cursor-pointer"
value={aboutMediaUrl} type="file"
onChange={(e) => setAboutMediaUrl(e.target.value)} accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm"
placeholder="https://.../video.mp4" onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, aboutBlobUrlRef, setAboutPendingFile);
e.target.value = "";
}}
/> />
{aboutMediaUrl.trim() ? ( {aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6"> <div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
{/\.(mp4|webm)(\?|$)/i.test(aboutMediaUrl) || {aboutPendingFile?.type.startsWith("video/") ||
aboutMediaUrl.toLowerCase().includes("video") ? ( /\.(mp4|webm)(\?|$)/i.test(aboutRemoteMediaUrl) ? (
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
<video <video
src={aboutMediaUrl} src={aboutBlobUrlRef.current || aboutRemoteMediaUrl}
controls controls
className="max-h-56 max-w-full rounded-lg" className="max-h-56 max-w-full rounded-lg"
playsInline playsInline
@ -867,7 +1012,7 @@ export default function ContentWebsite() {
) : ( ) : (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={aboutMediaUrl} src={aboutBlobUrlRef.current || aboutRemoteMediaUrl}
alt="" alt=""
className="max-h-56 object-contain" className="max-h-56 object-contain"
/> />
@ -876,9 +1021,44 @@ export default function ContentWebsite() {
) : ( ) : (
<div className="mt-4 flex flex-col items-center justify-center rounded-xl border-2 border-dashed bg-slate-50 p-10 text-center"> <div className="mt-4 flex flex-col items-center justify-center rounded-xl border-2 border-dashed bg-slate-50 p-10 text-center">
<ImageIcon className="mb-2 h-8 w-8 text-slate-400" /> <ImageIcon className="mb-2 h-8 w-8 text-slate-400" />
<p className="text-sm text-slate-500">No media URL yet</p> <p className="text-sm text-slate-500">No media yet</p>
</div> </div>
)} )}
{aboutMediaImageId != null ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={async () => {
const ok = await Swal.fire({
icon: "question",
title: "Remove current media?",
showCancelButton: true,
});
if (!ok.isConfirmed) return;
setSaving(true);
try {
await deleteAboutUsContentImage(aboutMediaImageId);
revokeBlobRef(aboutBlobUrlRef);
setAboutPendingFile(null);
setAboutRemoteMediaUrl("");
setAboutMediaImageId(null);
await Swal.fire({
icon: "success",
title: "Media removed",
timer: 1200,
showConfirmButton: false,
});
await loadAll();
} finally {
setSaving(false);
}
}}
>
Remove current media
</Button>
) : null}
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div> <div>
@ -1028,13 +1208,27 @@ export default function ContentWebsite() {
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-slate-700">Card image URL</label> <label className="text-sm font-medium text-slate-700">Card image</label>
<Input <Input
className="mt-2" className="mt-2 cursor-pointer"
value={productImgUrl} type="file"
onChange={(e) => setProductImgUrl(e.target.value)} accept="image/jpeg,image/png,image/gif,image/webp"
placeholder="https://..." onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, productBlobUrlRef, setProductPendingFile);
e.target.value = "";
}}
/> />
{productBlobUrlRef.current || productRemoteUrl ? (
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={productBlobUrlRef.current || productRemoteUrl}
alt=""
className="max-h-36 object-contain"
/>
</div>
) : null}
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
@ -1179,13 +1373,27 @@ export default function ContentWebsite() {
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-slate-700">Banner image URL</label> <label className="text-sm font-medium text-slate-700">Banner image</label>
<Input <Input
className="mt-2" className="mt-2 cursor-pointer"
value={serviceImgUrl} type="file"
onChange={(e) => setServiceImgUrl(e.target.value)} accept="image/jpeg,image/png,image/gif,image/webp"
placeholder="https://..." onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, serviceBlobUrlRef, setServicePendingFile);
e.target.value = "";
}}
/> />
{serviceBlobUrlRef.current || serviceRemoteUrl ? (
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={serviceBlobUrlRef.current || serviceRemoteUrl}
alt=""
className="max-h-36 object-contain"
/>
</div>
) : null}
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
@ -1302,7 +1510,7 @@ export default function ContentWebsite() {
{editingPartnerId ? "Edit partner" : "Add Partner"} {editingPartnerId ? "Edit partner" : "Add Partner"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Partner name and logo URL are shown on the homepage technology partners section. Partner name and logo are shown on the homepage technology partners section. Logo is stored in MinIO.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
@ -1315,13 +1523,27 @@ export default function ContentWebsite() {
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-slate-700">Logo image URL</label> <label className="text-sm font-medium text-slate-700">Logo image</label>
<Input <Input
className="mt-2" className="mt-2 cursor-pointer"
value={partnerImgUrl} type="file"
onChange={(e) => setPartnerImgUrl(e.target.value)} accept="image/jpeg,image/png,image/gif,image/webp"
placeholder="https://..." onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, partnerBlobUrlRef, setPartnerPendingFile);
e.target.value = "";
}}
/> />
{partnerBlobUrlRef.current || partnerRemoteUrl ? (
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={partnerBlobUrlRef.current || partnerRemoteUrl}
alt=""
className="max-h-24 max-w-full object-contain"
/>
</div>
) : null}
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
@ -1483,14 +1705,28 @@ export default function ContentWebsite() {
</div> </div>
<div> <div>
<label className="text-sm font-medium text-slate-700"> <label className="text-sm font-medium text-slate-700">
Banner image URL (optional) Banner image (optional)
</label> </label>
<Input <Input
className="mt-2" className="mt-2 cursor-pointer"
value={popupImgUrl} type="file"
onChange={(e) => setPopupImgUrl(e.target.value)} accept="image/jpeg,image/png,image/gif,image/webp"
placeholder="https://..." onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, popupBlobUrlRef, setPopupPendingFile);
e.target.value = "";
}}
/> />
{popupBlobUrlRef.current || popupRemoteUrl ? (
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={popupBlobUrlRef.current || popupRemoteUrl}
alt=""
className="max-h-36 object-contain"
/>
</div>
) : null}
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">

View File

@ -1,7 +1,9 @@
import { import {
httpDeleteInterceptor, httpDeleteInterceptor,
httpGetInterceptor, httpGetInterceptor,
httpPostFormDataInterceptor,
httpPostInterceptor, httpPostInterceptor,
httpPutFormDataInterceptor,
httpPutInterceptor, httpPutInterceptor,
} from "./http-config/http-interceptor-services"; } from "./http-config/http-interceptor-services";
@ -66,6 +68,11 @@ export async function saveHeroImage(body: {
return await httpPostInterceptor("/hero-content-images", body); return await httpPostInterceptor("/hero-content-images", body);
} }
/** Multipart: fields `hero_content_id`, `file` */
export async function saveHeroImageUpload(formData: FormData) {
return await httpPostFormDataInterceptor("/hero-content-images", formData);
}
export async function updateHeroImage( export async function updateHeroImage(
id: string, id: string,
body: { image_path?: string; image_url?: string }, body: { image_path?: string; image_url?: string },
@ -73,6 +80,11 @@ export async function updateHeroImage(
return await httpPutInterceptor(`/hero-content-images/${id}`, body); return await httpPutInterceptor(`/hero-content-images/${id}`, body);
} }
/** Multipart: field `file` */
export async function updateHeroImageUpload(id: string, formData: FormData) {
return await httpPutFormDataInterceptor(`/hero-content-images/${id}`, formData);
}
export async function getAboutContentsList() { export async function getAboutContentsList() {
return await httpGetInterceptor("/about-us-contents"); return await httpGetInterceptor("/about-us-contents");
} }
@ -96,6 +108,11 @@ export async function saveAboutUsMediaUrl(body: {
return await httpPostInterceptor("/about-us-content-images/url", body); return await httpPostInterceptor("/about-us-content-images/url", body);
} }
/** Multipart: fields `about_us_content_id`, `file` (image or mp4/webm) */
export async function saveAboutUsMediaUpload(formData: FormData) {
return await httpPostFormDataInterceptor("/about-us-content-images", formData);
}
export async function deleteAboutUsContentImage(id: number) { export async function deleteAboutUsContentImage(id: number) {
return await httpDeleteInterceptor(`/about-us-content-images/${id}`); return await httpDeleteInterceptor(`/about-us-content-images/${id}`);
} }
@ -141,6 +158,11 @@ export async function saveOurProductImage(body: {
return await httpPostInterceptor("/our-product-content-images", body); return await httpPostInterceptor("/our-product-content-images", body);
} }
/** Multipart: fields `our_product_content_id`, `file` */
export async function saveOurProductImageUpload(formData: FormData) {
return await httpPostFormDataInterceptor("/our-product-content-images", formData);
}
export async function updateOurProductImage( export async function updateOurProductImage(
id: string, id: string,
body: { image_path?: string; image_url?: string }, body: { image_path?: string; image_url?: string },
@ -148,6 +170,11 @@ export async function updateOurProductImage(
return await httpPutInterceptor(`/our-product-content-images/${id}`, body); return await httpPutInterceptor(`/our-product-content-images/${id}`, body);
} }
/** Multipart: field `file` */
export async function updateOurProductImageUpload(id: string, formData: FormData) {
return await httpPutFormDataInterceptor(`/our-product-content-images/${id}`, formData);
}
export async function getOurServiceContent() { export async function getOurServiceContent() {
return await httpGetInterceptor("/our-service-contents"); return await httpGetInterceptor("/our-service-contents");
} }
@ -189,6 +216,11 @@ export async function saveOurServiceImage(body: {
return await httpPostInterceptor("/our-service-content-images", body); return await httpPostInterceptor("/our-service-content-images", body);
} }
/** Multipart: fields `our_service_content_id`, `file` */
export async function saveOurServiceImageUpload(formData: FormData) {
return await httpPostFormDataInterceptor("/our-service-content-images", formData);
}
export async function updateOurServiceImage( export async function updateOurServiceImage(
id: string, id: string,
body: { image_path?: string; image_url?: string }, body: { image_path?: string; image_url?: string },
@ -196,6 +228,11 @@ export async function updateOurServiceImage(
return await httpPutInterceptor(`/our-service-content-images/${id}`, body); return await httpPutInterceptor(`/our-service-content-images/${id}`, body);
} }
/** Multipart: field `file` */
export async function updateOurServiceImageUpload(id: string, formData: FormData) {
return await httpPutFormDataInterceptor(`/our-service-content-images/${id}`, formData);
}
export async function getPartnerContents() { export async function getPartnerContents() {
return await httpGetInterceptor("/partner-contents"); return await httpGetInterceptor("/partner-contents");
} }
@ -219,6 +256,11 @@ export async function updatePartnerContent(
return await httpPutInterceptor(`/partner-contents/${id}`, body); return await httpPutInterceptor(`/partner-contents/${id}`, body);
} }
/** Multipart: field `file` — updates logo in MinIO */
export async function uploadPartnerLogo(id: string, formData: FormData) {
return await httpPostFormDataInterceptor(`/partner-contents/${id}/logo`, formData);
}
export async function deletePartnerContent(id: string) { export async function deletePartnerContent(id: string) {
return await httpDeleteInterceptor(`/partner-contents/${id}`); return await httpDeleteInterceptor(`/partner-contents/${id}`);
} }
@ -265,6 +307,11 @@ export async function savePopupNewsImage(body: {
return await httpPostInterceptor("/popup-news-content-images", body); return await httpPostInterceptor("/popup-news-content-images", body);
} }
/** Multipart: fields `popup_news_content_id`, `file`; optional `is_thumbnail` */
export async function savePopupNewsImageUpload(formData: FormData) {
return await httpPostFormDataInterceptor("/popup-news-content-images", formData);
}
export async function deletePopupNews(id: number) { export async function deletePopupNews(id: number) {
return await httpDeleteInterceptor(`/popup-news-contents/${id}`); return await httpDeleteInterceptor(`/popup-news-contents/${id}`);
} }

View File

@ -17,6 +17,10 @@ const axiosInterceptorInstance = axios.create({
// Request interceptor // Request interceptor
axiosInterceptorInstance.interceptors.request.use( axiosInterceptorInstance.interceptors.request.use(
(config) => { (config) => {
// Default instance uses application/json; FormData must not use JSON or File becomes {}.
if (config.data instanceof FormData) {
config.headers.delete("Content-Type");
}
console.log("Config interceptor : ", config); console.log("Config interceptor : ", config);
const accessToken = Cookies.get("access_token"); const accessToken = Cookies.get("access_token");
if (accessToken) { if (accessToken) {

View File

@ -35,6 +35,74 @@ export async function httpGetInterceptor(pathUrl: any) {
} }
} }
const clientKeyHeaders = {
"X-Client-Key": defaultHeaders["X-Client-Key"],
};
/** POST multipart (do not set Content-Type — browser sets boundary). */
export async function httpPostFormDataInterceptor(pathUrl: string, formData: FormData) {
const resCsrf = await getCsrfToken();
const csrfToken = resCsrf?.data?.csrf_token;
const mergedHeaders: Record<string, string> = {
...clientKeyHeaders,
...(csrfToken ? { "X-CSRF-TOKEN": csrfToken } : {}),
};
const response = await axiosInterceptorInstance
.post(pathUrl, formData, { headers: mergedHeaders })
.catch((error) => error.response);
console.log("Response interceptor : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else if (response?.status == 401) {
Cookies.set("is_logout", "true");
window.location.href = "/";
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
/** PUT multipart (do not set Content-Type — browser sets boundary). */
export async function httpPutFormDataInterceptor(pathUrl: string, formData: FormData) {
const resCsrf = await getCsrfToken();
const csrfToken = resCsrf?.data?.csrf_token;
const mergedHeaders: Record<string, string> = {
...clientKeyHeaders,
...(csrfToken ? { "X-CSRF-TOKEN": csrfToken } : {}),
};
const response = await axiosInterceptorInstance
.put(pathUrl, formData, { headers: mergedHeaders })
.catch((error) => error.response);
console.log("Response interceptor : ", response);
if (response?.status == 200 || response?.status == 201) {
return {
error: false,
message: "success",
data: response?.data,
};
} else if (response?.status == 401) {
Cookies.set("is_logout", "true");
window.location.href = "/";
} else {
return {
error: true,
message: response?.data?.message || response?.data || null,
data: null,
};
}
}
export async function httpPostInterceptor( export async function httpPostInterceptor(
pathUrl: any, pathUrl: any,
data: any, data: any,