qudoco-fe/components/main/content-website.tsx

2389 lines
87 KiB
TypeScript
Raw Normal View History

2026-02-17 10:02:35 +00:00
"use client";
2026-04-10 08:18:11 +00:00
import {
useCallback,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
2026-02-17 10:02:35 +00:00
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
2026-04-10 07:21:29 +00:00
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Eye, Pencil, ImageIcon, Loader2, Plus, Trash2 } from "lucide-react";
import Swal from "sweetalert2";
import type {
CmsAboutContent,
CmsHeroContent,
CmsPartnerContent,
CmsPopupContent,
CmsProductContent,
CmsServiceContent,
} from "@/types/cms-landing";
import {
apiDataList,
apiPayload,
apiRows,
deleteAboutUsContentImage,
deleteOurProductContent,
deleteOurServiceContent,
deletePartnerContent,
deletePopupNews,
getAboutContentsList,
getHeroContent,
getOurProductContent,
getOurServiceContent,
getPartnerContents,
getPopupNewsList,
saveAboutContent,
2026-04-10 08:18:11 +00:00
saveAboutUsMediaUpload,
2026-04-10 07:21:29 +00:00
saveHeroContent,
2026-04-10 08:18:11 +00:00
saveHeroImageUpload,
2026-04-10 07:21:29 +00:00
saveOurProductContent,
saveOurServiceContent,
savePartnerContent,
2026-04-10 08:18:11 +00:00
saveOurProductImageUpload,
saveOurServiceImageUpload,
2026-04-10 07:21:29 +00:00
savePopupNews,
2026-04-10 08:18:11 +00:00
savePopupNewsImageUpload,
2026-04-10 07:21:29 +00:00
updateAboutContent,
updateHeroContent,
2026-04-10 08:18:11 +00:00
updateHeroImageUpload,
2026-04-10 07:21:29 +00:00
updateOurProductContent,
2026-04-10 08:18:11 +00:00
updateOurProductImageUpload,
2026-04-10 07:21:29 +00:00
updateOurServiceContent,
2026-04-10 08:18:11 +00:00
updateOurServiceImageUpload,
2026-04-10 07:21:29 +00:00
updatePartnerContent,
updatePopupNews,
2026-04-10 08:18:11 +00:00
uploadPartnerLogo,
2026-04-10 07:21:29 +00:00
} from "@/service/cms-landing";
import { submitCmsContentSubmission } from "@/service/cms-content-submissions";
2026-02-17 10:02:35 +00:00
2026-04-10 08:18:11 +00:00
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);
}
type ContentWebsiteProps = {
/** User level 2: changes go through approval instead of live CMS APIs. */
contributorMode?: boolean;
/** Approver (or admin): load live CMS data but disable all edits (all tabs). */
viewOnly?: boolean;
/** Omit page title/actions row — parent supplies section headings (e.g. approver layout). */
hideHeader?: boolean;
/** Parent increments this (e.g. 1,2,3…) to switch the visible tab. */
tabFocusSignal?: number;
/** Tab id matching `TabsTrigger` values: hero | about | products | services | partners | popup */
tabFocusTarget?: string;
/** Increment (e.g. after approver applies CMS) to reload live data from API without remounting. */
liveDataReloadSignal?: number;
};
export default function ContentWebsite({
contributorMode = false,
viewOnly = false,
hideHeader = false,
tabFocusSignal = 0,
tabFocusTarget = "",
liveDataReloadSignal = 0,
}: ContentWebsiteProps) {
2026-02-17 10:02:35 +00:00
const [activeTab, setActiveTab] = useState("hero");
2026-04-10 07:21:29 +00:00
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(!contributorMode);
const canInteract = (!contributorMode || editMode) && !viewOnly;
const dimContributorPreview =
contributorMode && !editMode && !viewOnly;
2026-04-10 07:21:29 +00:00
const [heroId, setHeroId] = useState<string | null>(null);
const [heroImageId, setHeroImageId] = useState<string | null>(null);
const [heroPrimary, setHeroPrimary] = useState("");
const [heroSecondary, setHeroSecondary] = useState("");
const [heroDesc, setHeroDesc] = useState("");
const [heroCta1, setHeroCta1] = useState("");
const [heroCta2, setHeroCta2] = useState("");
2026-04-10 08:18:11 +00:00
const [heroRemoteUrl, setHeroRemoteUrl] = useState("");
const [heroPendingFile, setHeroPendingFile] = useState<File | null>(null);
const heroBlobUrlRef = useRef<string | null>(null);
2026-04-10 07:21:29 +00:00
const [aboutId, setAboutId] = useState<number | null>(null);
const [aboutPrimary, setAboutPrimary] = useState("");
const [aboutSecondary, setAboutSecondary] = useState("");
const [aboutDesc, setAboutDesc] = useState("");
const [aboutCta1, setAboutCta1] = useState("");
const [aboutCta2, setAboutCta2] = useState("");
2026-04-10 08:18:11 +00:00
const [aboutRemoteMediaUrl, setAboutRemoteMediaUrl] = useState("");
const [aboutPendingFile, setAboutPendingFile] = useState<File | null>(null);
const aboutBlobUrlRef = useRef<string | null>(null);
2026-04-10 07:21:29 +00:00
const [aboutMediaImageId, setAboutMediaImageId] = useState<number | null>(
null,
);
const [products, setProducts] = useState<CmsProductContent[]>([]);
const [productEditId, setProductEditId] = useState<string | null>(null);
const [productPrimary, setProductPrimary] = useState("");
const [productSecondary, setProductSecondary] = useState("");
const [productDesc, setProductDesc] = useState("");
const [productLinkUrl, setProductLinkUrl] = useState("");
2026-04-10 08:18:11 +00:00
const [productRemoteUrl, setProductRemoteUrl] = useState("");
const [productPendingFile, setProductPendingFile] = useState<File | null>(
null,
);
const productBlobUrlRef = useRef<string | null>(null);
2026-04-10 07:21:29 +00:00
const [productImageId, setProductImageId] = useState<string | null>(null);
const [productModalOpen, setProductModalOpen] = useState(false);
const [services, setServices] = useState<CmsServiceContent[]>([]);
const [serviceEditId, setServiceEditId] = useState<number | null>(null);
const [servicePrimary, setServicePrimary] = useState("");
const [serviceSecondary, setServiceSecondary] = useState("");
const [serviceDesc, setServiceDesc] = useState("");
const [serviceLinkUrl, setServiceLinkUrl] = useState("");
2026-04-10 08:18:11 +00:00
const [serviceRemoteUrl, setServiceRemoteUrl] = useState("");
const [servicePendingFile, setServicePendingFile] = useState<File | null>(
null,
);
const serviceBlobUrlRef = useRef<string | null>(null);
2026-04-10 07:21:29 +00:00
const [serviceImageId, setServiceImageId] = useState<string | null>(null);
const [serviceModalOpen, setServiceModalOpen] = useState(false);
const [partners, setPartners] = useState<CmsPartnerContent[]>([]);
const [partnerTitle, setPartnerTitle] = useState("");
2026-04-10 08:18:11 +00:00
const [partnerRemoteUrl, setPartnerRemoteUrl] = useState("");
const [partnerStoredPath, setPartnerStoredPath] = useState("");
const [partnerPendingFile, setPartnerPendingFile] = useState<File | null>(
null,
);
const partnerBlobUrlRef = useRef<string | null>(null);
2026-04-10 07:21:29 +00:00
const [editingPartnerId, setEditingPartnerId] = useState<string | null>(null);
const [partnerModalOpen, setPartnerModalOpen] = useState(false);
const [popups, setPopups] = useState<CmsPopupContent[]>([]);
const [popupEditId, setPopupEditId] = useState<number | null>(null);
const [popupPrimary, setPopupPrimary] = useState("");
const [popupSecondary, setPopupSecondary] = useState("");
const [popupDesc, setPopupDesc] = useState("");
const [popupCta1, setPopupCta1] = useState("");
const [popupCta2, setPopupCta2] = useState("");
2026-04-10 08:18:11 +00:00
const [popupRemoteUrl, setPopupRemoteUrl] = useState("");
const [popupPendingFile, setPopupPendingFile] = useState<File | null>(null);
const popupBlobUrlRef = useRef<string | null>(null);
2026-04-10 07:21:29 +00:00
const [popupModalOpen, setPopupModalOpen] = useState(false);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const heroRes = await getHeroContent();
const hero = apiPayload(heroRes) as CmsHeroContent | null;
if (hero?.id) {
setHeroId(hero.id);
setHeroPrimary(hero.primary_title ?? "");
setHeroSecondary(hero.secondary_title ?? "");
setHeroDesc(hero.description ?? "");
setHeroCta1(hero.primary_cta ?? "");
setHeroCta2(hero.secondary_cta_text ?? "");
const first = hero.images?.[0];
2026-04-10 08:18:11 +00:00
revokeBlobRef(heroBlobUrlRef);
setHeroPendingFile(null);
2026-04-10 07:21:29 +00:00
if (first?.image_url) {
2026-04-10 08:18:11 +00:00
setHeroRemoteUrl(first.image_url);
2026-04-10 07:21:29 +00:00
setHeroImageId(first.id ?? null);
} else {
2026-04-10 08:18:11 +00:00
setHeroRemoteUrl("");
2026-04-10 07:21:29 +00:00
setHeroImageId(null);
}
} else {
setHeroId(null);
setHeroImageId(null);
setHeroPrimary("");
setHeroSecondary("");
setHeroDesc("");
setHeroCta1("");
setHeroCta2("");
2026-04-10 08:18:11 +00:00
revokeBlobRef(heroBlobUrlRef);
setHeroPendingFile(null);
setHeroRemoteUrl("");
2026-04-10 07:21:29 +00:00
}
const aboutRes = await getAboutContentsList();
const aboutList = apiRows(aboutRes) as CmsAboutContent[];
const ab = aboutList[0];
if (ab) {
setAboutId(ab.id);
setAboutPrimary(ab.primary_title ?? "");
setAboutSecondary(ab.secondary_title ?? "");
setAboutDesc(ab.description ?? "");
setAboutCta1(ab.primary_cta ?? "");
setAboutCta2(ab.secondary_cta_text ?? "");
const am = ab.images?.[0];
const murl = am?.media_url?.trim() ?? "";
2026-04-10 08:18:11 +00:00
revokeBlobRef(aboutBlobUrlRef);
setAboutPendingFile(null);
setAboutRemoteMediaUrl(murl);
2026-04-10 07:21:29 +00:00
setAboutMediaImageId(am?.id ?? null);
} else {
setAboutId(null);
setAboutPrimary("");
setAboutSecondary("");
setAboutDesc("");
setAboutCta1("");
setAboutCta2("");
2026-04-10 08:18:11 +00:00
revokeBlobRef(aboutBlobUrlRef);
setAboutPendingFile(null);
setAboutRemoteMediaUrl("");
2026-04-10 07:21:29 +00:00
setAboutMediaImageId(null);
}
const prRes = await getOurProductContent();
setProducts(apiDataList<CmsProductContent>(prRes));
const svRes = await getOurServiceContent();
setServices(apiDataList<CmsServiceContent>(svRes));
const parRes = await getPartnerContents();
const parList = apiPayload(parRes) as CmsPartnerContent[] | null;
setPartners(Array.isArray(parList) ? parList : []);
const popRes = await getPopupNewsList(1, 50);
setPopups(apiRows(popRes) as CmsPopupContent[]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
useEffect(() => {
if (!viewOnly) return;
setProductModalOpen(false);
setServiceModalOpen(false);
setPartnerModalOpen(false);
setPopupModalOpen(false);
}, [viewOnly]);
useEffect(() => {
if (tabFocusSignal < 1 || !tabFocusTarget) return;
setActiveTab(tabFocusTarget);
}, [tabFocusSignal, tabFocusTarget]);
useEffect(() => {
if (liveDataReloadSignal < 1) return;
void loadAll();
}, [liveDataReloadSignal, loadAll]);
2026-04-10 07:21:29 +00:00
async function saveHeroTab() {
if (!heroPrimary.trim()) {
await Swal.fire({ icon: "warning", title: "Main title is required" });
return;
}
if (contributorMode && editMode) {
if (heroPendingFile) {
await Swal.fire({
icon: "info",
title: "Gunakan URL gambar",
text: "Sebagai kontributor, unggah file tidak didukung. Salin URL dari Media Library ke bidang Image URL.",
});
return;
}
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "hero",
title: `Hero: ${heroPrimary.slice(0, 80)}`,
payload: {
hero_id: heroId ?? "",
hero_image_id: heroImageId ?? "",
primary_title: heroPrimary,
secondary_title: heroSecondary,
description: heroDesc,
primary_cta: heroCta1,
secondary_cta_text: heroCta2,
image_url: (heroRemoteUrl ?? "").trim(),
},
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Diajukan untuk persetujuan",
text: "Perubahan Hero ada di My Content.",
timer: 2000,
showConfirmButton: false,
});
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const body = {
primary_title: heroPrimary,
secondary_title: heroSecondary,
description: heroDesc,
primary_cta: heroCta1,
secondary_cta_text: heroCta2,
};
let hid = heroId;
if (!hid) {
const res = await saveHeroContent(body);
const created = apiPayload(res) as CmsHeroContent | null;
if (!created?.id) {
await Swal.fire({ icon: "error", title: "Failed to create hero content" });
return;
}
hid = created.id;
setHeroId(hid);
} else {
const res = await updateHeroContent(hid, body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return;
}
}
2026-04-10 08:18:11 +00:00
if (heroPendingFile && hid) {
const fd = new FormData();
fd.append("file", heroPendingFile);
let imgRes;
2026-04-10 07:21:29 +00:00
if (heroImageId) {
2026-04-10 08:18:11 +00:00
imgRes = await updateHeroImageUpload(heroImageId, fd);
2026-04-10 07:21:29 +00:00
} else {
2026-04-10 08:18:11 +00:00
fd.append("hero_content_id", hid);
imgRes = await saveHeroImageUpload(fd);
}
if (imgRes?.error) {
await Swal.fire({
icon: "error",
title: "Hero image upload failed",
text: String(imgRes.message ?? ""),
2026-04-10 07:21:29 +00:00
});
2026-04-10 08:18:11 +00:00
return;
2026-04-10 07:21:29 +00:00
}
2026-04-10 08:18:11 +00:00
revokeBlobRef(heroBlobUrlRef);
setHeroPendingFile(null);
2026-04-10 07:21:29 +00:00
}
await Swal.fire({ icon: "success", title: "Hero section saved", timer: 1600, showConfirmButton: false });
await loadAll();
} finally {
setSaving(false);
}
}
async function saveAboutTab() {
if (!aboutPrimary.trim()) {
await Swal.fire({ icon: "warning", title: "Main title is required" });
return;
}
if (contributorMode && editMode) {
if (aboutPendingFile) {
await Swal.fire({
icon: "info",
title: "Gunakan URL media",
text: "Sebagai kontributor, unggah file tidak didukung. Gunakan URL dari Media Library.",
});
return;
}
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "about",
title: `About: ${aboutPrimary.slice(0, 80)}`,
payload: {
about_id: aboutId ?? undefined,
about_media_image_id: aboutMediaImageId ?? undefined,
primary_title: aboutPrimary,
secondary_title: aboutSecondary,
description: aboutDesc,
primary_cta: aboutCta1,
secondary_cta_text: aboutCta2,
media_url: (aboutRemoteMediaUrl ?? "").trim(),
},
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Diajukan untuk persetujuan",
timer: 2000,
showConfirmButton: false,
});
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const body: Record<string, string> = {
primary_title: aboutPrimary,
secondary_title: aboutSecondary,
description: aboutDesc,
primary_cta: aboutCta1,
secondary_cta_text: aboutCta2,
};
let aid = aboutId;
if (aboutId == null) {
const res = await saveAboutContent(body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") });
return;
}
const created = apiPayload(res) as CmsAboutContent | null;
aid = created?.id ?? null;
if (aid != null) setAboutId(aid);
} else {
const res = await updateAboutContent(aboutId, body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return;
}
}
2026-04-10 08:18:11 +00:00
if (aid != null && aboutPendingFile) {
if (aboutMediaImageId != null) {
await deleteAboutUsContentImage(aboutMediaImageId);
}
const fd = new FormData();
fd.append("about_us_content_id", String(aid));
fd.append("file", aboutPendingFile);
const mres = await saveAboutUsMediaUpload(fd);
if (mres?.error) {
await Swal.fire({
icon: "error",
title: "Media upload failed",
text: String(mres.message ?? ""),
2026-04-10 07:21:29 +00:00
});
2026-04-10 08:18:11 +00:00
return;
2026-04-10 07:21:29 +00:00
}
2026-04-10 08:18:11 +00:00
revokeBlobRef(aboutBlobUrlRef);
setAboutPendingFile(null);
2026-04-10 07:21:29 +00:00
}
await Swal.fire({ icon: "success", title: "About Us saved", timer: 1600, showConfirmButton: false });
await loadAll();
} finally {
setSaving(false);
}
}
function beginEditProduct(p: CmsProductContent | null) {
if (!p) {
setProductEditId(null);
setProductPrimary("");
setProductSecondary("");
setProductDesc("");
setProductLinkUrl("");
2026-04-10 08:18:11 +00:00
revokeBlobRef(productBlobUrlRef);
setProductPendingFile(null);
setProductRemoteUrl("");
2026-04-10 07:21:29 +00:00
setProductImageId(null);
return;
}
setProductEditId(p.id);
setProductPrimary(p.primary_title ?? "");
setProductSecondary(p.secondary_title ?? "");
setProductDesc(p.description ?? "");
setProductLinkUrl(p.link_url ?? "");
2026-04-10 07:21:29 +00:00
const im = p.images?.[0];
2026-04-10 08:18:11 +00:00
revokeBlobRef(productBlobUrlRef);
setProductPendingFile(null);
setProductRemoteUrl(im?.image_url?.trim() ?? "");
2026-04-10 07:21:29 +00:00
setProductImageId(im?.id ?? null);
}
function openProductModalCreate() {
beginEditProduct(null);
setProductModalOpen(true);
}
function openProductModalEdit(p: CmsProductContent) {
beginEditProduct(p);
setProductModalOpen(true);
}
async function saveProductDraft() {
if (!productPrimary.trim()) {
await Swal.fire({ icon: "warning", title: "Product title is required" });
return;
}
if (contributorMode && editMode) {
if (productPendingFile) {
await Swal.fire({
icon: "info",
title: "Gunakan URL gambar",
text: "Sebagai kontributor, gunakan URL dari Media Library untuk gambar produk.",
});
return;
}
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "product",
title: `Product: ${productPrimary.slice(0, 80)}`,
payload: {
product_id: productEditId ?? "",
product_image_id: productImageId ?? "",
primary_title: productPrimary,
secondary_title: productSecondary,
description: productDesc,
link_url: productLinkUrl,
image_url: (productRemoteUrl ?? "").trim(),
},
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Diajukan untuk persetujuan",
timer: 1800,
showConfirmButton: false,
});
beginEditProduct(null);
setProductModalOpen(false);
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const body = {
primary_title: productPrimary,
secondary_title: productSecondary,
description: productDesc,
link_url: productLinkUrl,
2026-04-10 07:21:29 +00:00
};
let pid = productEditId;
if (!pid) {
const res = await saveOurProductContent(body);
const created = apiPayload(res) as CmsProductContent | null;
if (!created?.id) {
await Swal.fire({ icon: "error", title: "Failed to create product" });
return;
}
pid = created.id;
} else {
const res = await updateOurProductContent(pid, body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return;
}
}
2026-04-10 08:18:11 +00:00
if (productPendingFile && pid) {
const fd = new FormData();
fd.append("file", productPendingFile);
let ires;
2026-04-10 07:21:29 +00:00
if (productImageId) {
2026-04-10 08:18:11 +00:00
ires = await updateOurProductImageUpload(productImageId, fd);
2026-04-10 07:21:29 +00:00
} else {
2026-04-10 08:18:11 +00:00
fd.append("our_product_content_id", pid);
ires = await saveOurProductImageUpload(fd);
}
if (ires?.error) {
await Swal.fire({
icon: "error",
title: "Product image upload failed",
text: String(ires.message ?? ""),
2026-04-10 07:21:29 +00:00
});
2026-04-10 08:18:11 +00:00
return;
2026-04-10 07:21:29 +00:00
}
2026-04-10 08:18:11 +00:00
revokeBlobRef(productBlobUrlRef);
setProductPendingFile(null);
2026-04-10 07:21:29 +00:00
}
await Swal.fire({ icon: "success", title: "Product saved", timer: 1400, showConfirmButton: false });
await loadAll();
beginEditProduct(null);
setProductModalOpen(false);
} finally {
setSaving(false);
}
}
async function removeProduct(id: string) {
const ok = await Swal.fire({
icon: "warning",
title: "Delete this product?",
showCancelButton: true,
});
if (!ok.isConfirmed) return;
if (contributorMode && editMode) {
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "product",
title: `Delete product ${id}`,
payload: { action: "delete", product_id: id },
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Penghapusan diajukan",
timer: 1600,
showConfirmButton: false,
});
if (productEditId === id) {
beginEditProduct(null);
setProductModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
await deleteOurProductContent(id);
if (productEditId === id) {
beginEditProduct(null);
setProductModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
}
function beginEditService(s: CmsServiceContent | null) {
if (!s) {
setServiceEditId(null);
setServicePrimary("");
setServiceSecondary("");
setServiceDesc("");
setServiceLinkUrl("");
2026-04-10 08:18:11 +00:00
revokeBlobRef(serviceBlobUrlRef);
setServicePendingFile(null);
setServiceRemoteUrl("");
2026-04-10 07:21:29 +00:00
setServiceImageId(null);
return;
}
setServiceEditId(s.id);
setServicePrimary(s.primary_title ?? "");
setServiceSecondary(s.secondary_title ?? "");
setServiceDesc(s.description ?? "");
setServiceLinkUrl(s.link_url ?? "");
2026-04-10 07:21:29 +00:00
const im = s.images?.[0];
2026-04-10 08:18:11 +00:00
revokeBlobRef(serviceBlobUrlRef);
setServicePendingFile(null);
setServiceRemoteUrl(im?.image_url?.trim() ?? "");
2026-04-10 07:21:29 +00:00
setServiceImageId(im?.id != null ? String(im.id) : null);
}
function openServiceModalCreate() {
beginEditService(null);
setServiceModalOpen(true);
}
function openServiceModalEdit(s: CmsServiceContent) {
beginEditService(s);
setServiceModalOpen(true);
}
async function saveServiceDraft() {
if (!servicePrimary.trim()) {
await Swal.fire({ icon: "warning", title: "Service title is required" });
return;
}
if (contributorMode && editMode) {
if (servicePendingFile) {
await Swal.fire({
icon: "info",
title: "Gunakan URL gambar",
text: "Sebagai kontributor, gunakan URL dari Media Library untuk gambar layanan.",
});
return;
}
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "service",
title: `Service: ${servicePrimary.slice(0, 80)}`,
payload: {
service_id: serviceEditId ?? undefined,
service_image_id: serviceImageId ?? "",
primary_title: servicePrimary,
secondary_title: serviceSecondary,
description: serviceDesc,
link_url: serviceLinkUrl,
image_url: (serviceRemoteUrl ?? "").trim(),
},
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Diajukan untuk persetujuan",
timer: 1800,
showConfirmButton: false,
});
beginEditService(null);
setServiceModalOpen(false);
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const body = {
primary_title: servicePrimary,
secondary_title: serviceSecondary,
description: serviceDesc,
};
let sid = serviceEditId;
if (sid == null) {
const res = await saveOurServiceContent(body);
const created = apiPayload(res) as CmsServiceContent | null;
if (created?.id == null) {
await Swal.fire({ icon: "error", title: "Failed to create service" });
return;
}
sid = created.id;
} else {
const res = await updateOurServiceContent(sid, body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return;
}
}
2026-04-10 08:18:11 +00:00
if (servicePendingFile && sid != null) {
const fd = new FormData();
fd.append("file", servicePendingFile);
let ires;
2026-04-10 07:21:29 +00:00
if (serviceImageId) {
2026-04-10 08:18:11 +00:00
ires = await updateOurServiceImageUpload(serviceImageId, fd);
2026-04-10 07:21:29 +00:00
} else {
2026-04-10 08:18:11 +00:00
fd.append("our_service_content_id", String(sid));
ires = await saveOurServiceImageUpload(fd);
}
if (ires?.error) {
await Swal.fire({
icon: "error",
title: "Service image upload failed",
text: String(ires.message ?? ""),
2026-04-10 07:21:29 +00:00
});
2026-04-10 08:18:11 +00:00
return;
2026-04-10 07:21:29 +00:00
}
2026-04-10 08:18:11 +00:00
revokeBlobRef(serviceBlobUrlRef);
setServicePendingFile(null);
2026-04-10 07:21:29 +00:00
}
await Swal.fire({ icon: "success", title: "Service saved", timer: 1400, showConfirmButton: false });
await loadAll();
beginEditService(null);
setServiceModalOpen(false);
} finally {
setSaving(false);
}
}
async function removeService(id: number) {
const ok = await Swal.fire({
icon: "warning",
title: "Delete this service?",
showCancelButton: true,
});
if (!ok.isConfirmed) return;
if (contributorMode && editMode) {
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "service",
title: `Delete service ${id}`,
payload: { action: "delete", service_id: id },
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Penghapusan diajukan",
timer: 1600,
showConfirmButton: false,
});
if (serviceEditId === id) {
beginEditService(null);
setServiceModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
await deleteOurServiceContent(id);
if (serviceEditId === id) {
beginEditService(null);
setServiceModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
}
function beginEditPartner(p: CmsPartnerContent | null) {
if (!p) {
setEditingPartnerId(null);
setPartnerTitle("");
2026-04-10 08:18:11 +00:00
revokeBlobRef(partnerBlobUrlRef);
setPartnerPendingFile(null);
setPartnerRemoteUrl("");
setPartnerStoredPath("");
2026-04-10 07:21:29 +00:00
return;
}
setEditingPartnerId(p.id);
setPartnerTitle(p.primary_title ?? "");
2026-04-10 08:18:11 +00:00
revokeBlobRef(partnerBlobUrlRef);
setPartnerPendingFile(null);
setPartnerRemoteUrl(p.image_url ?? "");
setPartnerStoredPath(p.image_path ?? "");
2026-04-10 07:21:29 +00:00
}
function openPartnerModalCreate() {
beginEditPartner(null);
setPartnerModalOpen(true);
}
function openPartnerModalEdit(p: CmsPartnerContent) {
beginEditPartner(p);
setPartnerModalOpen(true);
}
async function savePartnerRow() {
if (!partnerTitle.trim()) {
await Swal.fire({ icon: "warning", title: "Partner name is required" });
return;
}
if (contributorMode && editMode) {
if (partnerPendingFile) {
await Swal.fire({
icon: "info",
title: "Gunakan URL logo",
text: "Sebagai kontributor, gunakan URL logo dari Media Library (isi URL gambar).",
});
return;
}
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "partner",
title: `Partner: ${partnerTitle.slice(0, 80)}`,
payload: {
partner_id: editingPartnerId ?? "",
primary_title: partnerTitle.trim(),
image_path: partnerStoredPath,
image_url: (partnerRemoteUrl ?? "").trim(),
},
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Diajukan untuk persetujuan",
timer: 1800,
showConfirmButton: false,
});
beginEditPartner(null);
setPartnerModalOpen(false);
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const body = {
primary_title: partnerTitle.trim(),
};
2026-04-10 08:18:11 +00:00
let partnerId = editingPartnerId;
2026-04-10 07:21:29 +00:00
if (editingPartnerId) {
2026-04-10 08:18:11 +00:00
const res = await updatePartnerContent(editingPartnerId, {
primary_title: body.primary_title,
image_path: partnerStoredPath,
image_url: partnerRemoteUrl,
});
2026-04-10 07:21:29 +00:00
if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return;
}
} else {
const res = await savePartnerContent(body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") });
return;
}
2026-04-10 08:18:11 +00:00
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);
2026-04-10 07:21:29 +00:00
}
beginEditPartner(null);
setPartnerModalOpen(false);
await Swal.fire({ icon: "success", title: "Partner saved", timer: 1400, showConfirmButton: false });
await loadAll();
} finally {
setSaving(false);
}
}
async function removePartner(id: string) {
const ok = await Swal.fire({
icon: "warning",
title: "Delete this partner?",
showCancelButton: true,
});
if (!ok.isConfirmed) return;
if (contributorMode && editMode) {
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "partner",
title: `Delete partner ${id}`,
payload: { action: "delete", partner_id: id },
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Penghapusan diajukan",
timer: 1600,
showConfirmButton: false,
});
if (editingPartnerId === id) {
beginEditPartner(null);
setPartnerModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
await deletePartnerContent(id);
if (editingPartnerId === id) {
beginEditPartner(null);
setPartnerModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
}
function beginEditPopup(p: CmsPopupContent | null) {
if (!p) {
setPopupEditId(null);
setPopupPrimary("");
setPopupSecondary("");
setPopupDesc("");
setPopupCta1("");
setPopupCta2("");
2026-04-10 08:18:11 +00:00
revokeBlobRef(popupBlobUrlRef);
setPopupPendingFile(null);
setPopupRemoteUrl("");
2026-04-10 07:21:29 +00:00
return;
}
setPopupEditId(p.id);
setPopupPrimary(p.primary_title ?? "");
setPopupSecondary(p.secondary_title ?? "");
setPopupDesc(p.description ?? "");
setPopupCta1(p.primary_cta ?? "");
setPopupCta2(p.secondary_cta_text ?? "");
2026-04-10 08:18:11 +00:00
revokeBlobRef(popupBlobUrlRef);
setPopupPendingFile(null);
setPopupRemoteUrl(p.images?.[0]?.media_url?.trim() ?? "");
2026-04-10 07:21:29 +00:00
}
function openPopupModalCreate() {
beginEditPopup(null);
setPopupModalOpen(true);
}
function openPopupModalEdit(p: CmsPopupContent) {
beginEditPopup(p);
setPopupModalOpen(true);
}
async function savePopupDraft() {
if (!popupPrimary.trim()) {
await Swal.fire({ icon: "warning", title: "Main title is required" });
return;
}
if (contributorMode && editMode) {
if (popupPendingFile) {
await Swal.fire({
icon: "info",
title: "Gunakan URL gambar",
text: "Sebagai kontributor, gunakan URL banner dari Media Library.",
});
return;
}
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "popup",
title: `Pop-up: ${popupPrimary.slice(0, 80)}`,
payload: {
popup_id: popupEditId ?? undefined,
primary_title: popupPrimary,
secondary_title: popupSecondary,
description: popupDesc,
primary_cta: popupCta1,
secondary_cta_text: popupCta2,
media_url: (popupRemoteUrl ?? "").trim(),
},
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Diajukan untuk persetujuan",
timer: 1800,
showConfirmButton: false,
});
beginEditPopup(null);
setPopupModalOpen(false);
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const body = {
primary_title: popupPrimary,
secondary_title: popupSecondary,
description: popupDesc,
primary_cta: popupCta1,
secondary_cta_text: popupCta2,
};
let pid = popupEditId;
if (pid == null) {
const res = await savePopupNews(body);
if (res?.error) {
await Swal.fire({ icon: "error", title: "Save failed", text: String(res.message ?? "") });
return;
}
2026-04-10 08:18:11 +00:00
const created = apiPayload(res) as CmsPopupContent | null;
pid = created?.id ?? null;
if (pid == null) {
await Swal.fire({
icon: "error",
title: "Save failed",
text: "Could not read new pop-up id from server.",
});
return;
}
2026-04-10 07:21:29 +00:00
} else {
const res = await updatePopupNews(pid, { id: pid, ...body });
if (res?.error) {
await Swal.fire({ icon: "error", title: "Update failed", text: String(res.message ?? "") });
return;
}
}
2026-04-10 08:18:11 +00:00
if (popupPendingFile && pid != null) {
const fd = new FormData();
fd.append("popup_news_content_id", String(pid));
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);
2026-04-10 07:21:29 +00:00
}
await Swal.fire({ icon: "success", title: "Pop up saved", timer: 1600, showConfirmButton: false });
await loadAll();
beginEditPopup(null);
setPopupModalOpen(false);
} finally {
setSaving(false);
}
}
async function removePopup(id: number) {
const ok = await Swal.fire({
icon: "warning",
title: "Delete this pop up?",
showCancelButton: true,
});
if (!ok.isConfirmed) return;
if (contributorMode && editMode) {
setSaving(true);
try {
const res = await submitCmsContentSubmission({
domain: "popup",
title: `Delete popup ${id}`,
payload: { action: "delete", popup_id: id },
});
if ((res as { error?: boolean })?.error) {
await Swal.fire({
icon: "error",
title: "Pengajuan gagal",
text: String((res as { message?: unknown })?.message ?? ""),
});
return;
}
await Swal.fire({
icon: "success",
title: "Penghapusan diajukan",
timer: 1600,
showConfirmButton: false,
});
if (popupEditId === id) {
beginEditPopup(null);
setPopupModalOpen(false);
}
await loadAll();
} finally {
setSaving(false);
}
return;
}
2026-04-10 07:21:29 +00:00
setSaving(true);
try {
const res = await deletePopupNews(id);
if (res?.error) {
await Swal.fire({
icon: "error",
title: "Delete failed",
text: String(res.message ?? "Could not delete pop up."),
});
return;
}
2026-04-10 07:21:29 +00:00
if (popupEditId === id) {
beginEditPopup(null);
setPopupModalOpen(false);
}
await loadAll();
await Swal.fire({ icon: "success", title: "Deleted", timer: 1200, showConfirmButton: false });
2026-04-10 07:21:29 +00:00
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="flex min-h-[320px] items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-[#966314]" />
</div>
);
}
2026-02-17 10:02:35 +00:00
return (
<div className="space-y-6">
{!hideHeader ? (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-800">
Content Website
</h1>
<p className="mt-1 text-sm text-slate-500">
{viewOnly
? "Konten live di semua tab: hanya lihat. Pengajuan perubahan ditangani di bagian atas halaman."
: "Update homepage content, products, services, and partners."}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="button"
variant="outline"
className="rounded-lg"
onClick={() => window.open("/", "_blank")}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
{contributorMode ? (
<Button
type="button"
className={`rounded-lg ${editMode ? "bg-amber-600 hover:bg-amber-700" : "bg-blue-600 hover:bg-blue-700"}`}
onClick={() => setEditMode((v) => !v)}
>
<Pencil className="mr-2 h-4 w-4" />
{editMode ? "Exit Edit Mode" : "Edit Mode"}
</Button>
) : null}
</div>
2026-02-17 10:02:35 +00:00
</div>
) : viewOnly ? (
<div className="flex flex-wrap justify-end gap-2">
2026-04-10 07:21:29 +00:00
<Button
type="button"
variant="outline"
className="rounded-lg"
onClick={() => window.open("/", "_blank")}
>
<Eye className="mr-2 h-4 w-4" />
Preview website
2026-02-17 10:02:35 +00:00
</Button>
</div>
) : null}
2026-02-17 10:02:35 +00:00
{contributorMode && !editMode && !viewOnly ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Aktifkan <strong>Edit Mode</strong> untuk mengusulkan perubahan. Perubahan akan masuk ke{" "}
<strong>My Content</strong> menunggu persetujuan approver. Unggah file gambar tidak tersedia sebagai kontributor;
gunakan URL dari Media Library pada bidang yang disediakan.
</p>
) : null}
<div
className={
dimContributorPreview
? "pointer-events-none select-none opacity-50"
: ""
}
>
2026-02-17 10:02:35 +00:00
<Tabs value={activeTab} onValueChange={setActiveTab}>
2026-04-10 07:21:29 +00:00
<TabsList className="h-auto flex-wrap rounded-xl border bg-white p-1">
2026-02-17 10:02:35 +00:00
<TabsTrigger value="hero" className="rounded-lg">
Hero Section
</TabsTrigger>
<TabsTrigger value="about" className="rounded-lg">
About Us
</TabsTrigger>
<TabsTrigger value="products" className="rounded-lg">
Our Products
</TabsTrigger>
<TabsTrigger value="services" className="rounded-lg">
Our Services
</TabsTrigger>
<TabsTrigger value="partners" className="rounded-lg">
Technology Partners
</TabsTrigger>
<TabsTrigger value="popup" className="rounded-lg">
Pop Up
</TabsTrigger>
</TabsList>
2026-04-10 07:21:29 +00:00
<TabsContent value="hero" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
2026-04-10 07:21:29 +00:00
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-700">
Main Title <span className="text-red-600">*</span>
</label>
2026-04-10 07:21:29 +00:00
<Input
className="mt-2"
value={heroPrimary}
onChange={(e) => setHeroPrimary(e.target.value)}
placeholder="Headline"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">
Subtitle <span className="text-xs font-normal text-slate-500">(optional)</span>
</label>
2026-04-10 07:21:29 +00:00
<Input
className="mt-2"
value={heroSecondary}
onChange={(e) => setHeroSecondary(e.target.value)}
placeholder=""
2026-04-10 07:21:29 +00:00
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700">
Description <span className="text-xs font-normal text-slate-500">(optional)</span>
</label>
2026-04-10 07:21:29 +00:00
<Textarea
className="mt-2 min-h-[120px]"
value={heroDesc}
onChange={(e) => setHeroDesc(e.target.value)}
placeholder=""
2026-04-10 07:21:29 +00:00
/>
</div>
<div>
2026-04-10 08:18:11 +00:00
<label className="text-sm font-medium text-slate-700">Hero image</label>
2026-04-10 07:21:29 +00:00
<p className="mb-2 text-xs text-slate-500">
{contributorMode
? "Tempel URL gambar dari Media Library (kontributor tidak dapat mengunggah file langsung)."
: "Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero."}
2026-04-10 07:21:29 +00:00
</p>
{contributorMode ? (
<Input
className="mt-1"
type="url"
placeholder="https://…"
value={heroRemoteUrl}
onChange={(e) => setHeroRemoteUrl(e.target.value)}
/>
) : (
<Input
className="mt-1 cursor-pointer"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, heroBlobUrlRef, setHeroPendingFile);
e.target.value = "";
}}
/>
)}
2026-04-10 08:18:11 +00:00
{heroBlobUrlRef.current || heroRemoteUrl ? (
2026-04-10 07:21:29 +00:00
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
2026-04-10 08:18:11 +00:00
<img
src={heroBlobUrlRef.current || heroRemoteUrl}
alt=""
className="max-h-48 object-contain"
/>
2026-04-10 07:21:29 +00:00
</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">
<ImageIcon className="mb-2 h-8 w-8 text-slate-400" />
2026-04-10 08:18:11 +00:00
<p className="text-sm text-slate-500">No hero image yet</p>
2026-04-10 07:21:29 +00:00
</div>
)}
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-700">
Primary CTA Text <span className="text-xs font-normal text-slate-500">(optional)</span>
</label>
2026-04-10 07:21:29 +00:00
<Input
className="mt-2"
value={heroCta1}
onChange={(e) => setHeroCta1(e.target.value)}
placeholder=""
2026-04-10 07:21:29 +00:00
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">
Secondary CTA Text <span className="text-xs font-normal text-slate-500">(optional)</span>
</label>
2026-04-10 07:21:29 +00:00
<Input
className="mt-2"
value={heroCta2}
onChange={(e) => setHeroCta2(e.target.value)}
placeholder=""
2026-04-10 07:21:29 +00:00
/>
</div>
</div>
<Button type="button" onClick={saveHeroTab} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save Hero
</Button>
</CardContent>
</Card>
</fieldset>
2026-04-10 07:21:29 +00:00
</TabsContent>
<TabsContent value="about" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
2026-04-10 07:21:29 +00:00
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-700">Main Title</label>
<Input
className="mt-2"
value={aboutPrimary}
onChange={(e) => setAboutPrimary(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Subtitle</label>
<Input
className="mt-2"
value={aboutSecondary}
onChange={(e) => setAboutSecondary(e.target.value)}
placeholder="—"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Description</label>
<Textarea
className="mt-2 min-h-[120px]"
value={aboutDesc}
onChange={(e) => setAboutDesc(e.target.value)}
/>
</div>
<div>
2026-04-10 08:18:11 +00:00
<label className="text-sm font-medium text-slate-700">Media (image or video)</label>
2026-04-10 07:21:29 +00:00
<p className="mb-2 text-xs text-slate-500">
{contributorMode
? "Tempel URL gambar atau video dari Media Library."
: "Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page."}
2026-04-10 07:21:29 +00:00
</p>
{contributorMode ? (
<Input
className="mt-1"
type="url"
placeholder="https://…"
value={aboutRemoteMediaUrl}
onChange={(e) => setAboutRemoteMediaUrl(e.target.value)}
/>
) : (
<Input
className="mt-1 cursor-pointer"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm"
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, aboutBlobUrlRef, setAboutPendingFile);
e.target.value = "";
}}
/>
)}
2026-04-10 08:18:11 +00:00
{aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
2026-04-10 07:21:29 +00:00
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
2026-04-10 08:18:11 +00:00
{aboutPendingFile?.type.startsWith("video/") ||
/\.(mp4|webm)(\?|$)/i.test(aboutRemoteMediaUrl) ? (
2026-04-10 07:21:29 +00:00
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
2026-04-10 08:18:11 +00:00
src={aboutBlobUrlRef.current || aboutRemoteMediaUrl}
2026-04-10 07:21:29 +00:00
controls
className="max-h-56 max-w-full rounded-lg"
playsInline
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
2026-04-10 08:18:11 +00:00
src={aboutBlobUrlRef.current || aboutRemoteMediaUrl}
2026-04-10 07:21:29 +00:00
alt=""
className="max-h-56 object-contain"
/>
)}
</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">
<ImageIcon className="mb-2 h-8 w-8 text-slate-400" />
2026-04-10 08:18:11 +00:00
<p className="text-sm text-slate-500">No media yet</p>
2026-04-10 07:21:29 +00:00
</div>
)}
2026-04-10 08:18:11 +00:00
{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}
2026-04-10 07:21:29 +00:00
</div>
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-700">Primary CTA Text</label>
<Input
className="mt-2"
value={aboutCta1}
onChange={(e) => setAboutCta1(e.target.value)}
placeholder="—"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Secondary CTA Text</label>
<Input
className="mt-2"
value={aboutCta2}
onChange={(e) => setAboutCta2(e.target.value)}
placeholder="—"
/>
</div>
</div>
<Button type="button" onClick={saveAboutTab} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save About Us
</Button>
</CardContent>
</Card>
</fieldset>
2026-04-10 07:21:29 +00:00
</TabsContent>
<TabsContent value="products" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
2026-04-10 07:21:29 +00:00
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p className="text-sm text-slate-600">
Manage product listings on the homepage.
</p>
<Button
type="button"
className="shrink-0 rounded-lg bg-blue-600 hover:bg-blue-700"
onClick={openProductModalCreate}
>
<Plus className="mr-2 h-4 w-4" />
Add Product
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => {
const img = p.images?.[0]?.image_url;
return (
<div
key={p.id}
className={`flex flex-col overflow-hidden rounded-xl border bg-white shadow-sm ${
productModalOpen && productEditId === p.id
? "ring-2 ring-blue-500"
: ""
}`}
>
<button
type="button"
onClick={() => openProductModalEdit(p)}
className="relative aspect-[4/3] w-full bg-slate-100"
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={img} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-slate-400">
<ImageIcon className="h-10 w-10" />
</div>
)}
</button>
<div className="flex flex-1 flex-col p-4">
<h3 className="line-clamp-2 font-semibold text-slate-900">
{p.primary_title || "Untitled"}
</h3>
<p className="mt-1 line-clamp-3 text-sm text-slate-600">
{p.description || "—"}
</p>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex-1"
onClick={() => openProductModalEdit(p)}
>
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600"
onClick={() => removeProduct(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
<Dialog
open={productModalOpen}
onOpenChange={(open) => {
setProductModalOpen(open);
if (!open) beginEditProduct(null);
}}
>
<DialogContent className="max-h-[min(90vh,640px)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{productEditId ? "Edit product" : "Add Product"}
</DialogTitle>
<DialogDescription>
Homepage shows each product as a full-width row (image and text alternate). Use one line per bullet in
Description; optional &quot;Label: detail&quot; formats the label in bold. Product link powers the Learn
More action.
2026-04-10 07:21:29 +00:00
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="text-sm font-medium text-slate-700">Product title</label>
<Input
className="mt-2"
value={productPrimary}
onChange={(e) => setProductPrimary(e.target.value)}
/>
</div>
<div className="sm:col-span-2">
<label className="text-sm font-medium text-slate-700">Subtitle (optional)</label>
<Input
className="mt-2"
value={productSecondary}
onChange={(e) => setProductSecondary(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Description</label>
<Textarea
className="mt-2 min-h-[100px]"
value={productDesc}
onChange={(e) => setProductDesc(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Product link (optional)</label>
<Input
className="mt-2"
type="text"
placeholder="https://… or /path on this site"
value={productLinkUrl}
onChange={(e) => setProductLinkUrl(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-500">
Shown as &quot;Learn More&quot; on the landing page. Leave empty to disable the link.
</p>
</div>
2026-04-10 07:21:29 +00:00
<div>
2026-04-10 08:18:11 +00:00
<label className="text-sm font-medium text-slate-700">Card image</label>
{contributorMode ? (
<Input
className="mt-2"
type="url"
placeholder="Image URL from Media Library"
value={productRemoteUrl}
onChange={(e) => setProductRemoteUrl(e.target.value)}
/>
) : (
<Input
className="mt-2 cursor-pointer"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, productBlobUrlRef, setProductPendingFile);
e.target.value = "";
}}
/>
)}
2026-04-10 08:18:11 +00:00
{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}
2026-04-10 07:21:29 +00:00
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => {
setProductModalOpen(false);
beginEditProduct(null);
}}
>
Cancel
</Button>
<Button type="button" onClick={saveProductDraft} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</fieldset>
2026-04-10 07:21:29 +00:00
</TabsContent>
<TabsContent value="services" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
2026-04-10 07:21:29 +00:00
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p className="text-sm text-slate-600">
Manage service listings on the homepage.
</p>
<Button
type="button"
className="shrink-0 rounded-lg bg-blue-600 hover:bg-blue-700"
onClick={openServiceModalCreate}
>
<Plus className="mr-2 h-4 w-4" />
Add Service
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{services.map((s) => {
const img = s.images?.[0]?.image_url;
return (
<div
key={s.id}
className={`flex flex-col overflow-hidden rounded-xl border bg-white shadow-sm ${
serviceModalOpen && serviceEditId === s.id
? "ring-2 ring-blue-500"
: ""
}`}
>
<button
type="button"
onClick={() => openServiceModalEdit(s)}
className="relative aspect-video w-full bg-slate-100"
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={img} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-slate-400">
<ImageIcon className="h-10 w-10" />
</div>
)}
</button>
<div className="flex flex-1 flex-col p-4">
<h3 className="line-clamp-2 font-semibold text-slate-900">
{s.primary_title || "Untitled"}
</h3>
<p className="mt-1 line-clamp-3 text-sm text-slate-600">
{s.description || "—"}
</p>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex-1"
onClick={() => openServiceModalEdit(s)}
>
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600"
onClick={() => removeService(s.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
<Dialog
open={serviceModalOpen}
onOpenChange={(open) => {
setServiceModalOpen(open);
if (!open) beginEditService(null);
}}
>
<DialogContent className="max-h-[min(90vh,640px)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{serviceEditId != null ? "Edit service" : "Add Service"}
</DialogTitle>
<DialogDescription>
Same fields as Products: homepage shows each service as a full-width alternating row. One line per
bullet in Description; optional &quot;Label: detail&quot; bolds the label. Service link powers Learn
More.
2026-04-10 07:21:29 +00:00
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="text-sm font-medium text-slate-700">Service title</label>
<Input
className="mt-2"
value={servicePrimary}
onChange={(e) => setServicePrimary(e.target.value)}
/>
</div>
<div className="sm:col-span-2">
<label className="text-sm font-medium text-slate-700">Subtitle (optional)</label>
<Input
className="mt-2"
value={serviceSecondary}
onChange={(e) => setServiceSecondary(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Description</label>
<Textarea
className="mt-2 min-h-[100px]"
value={serviceDesc}
onChange={(e) => setServiceDesc(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Service link (optional)</label>
<Input
className="mt-2"
type="text"
placeholder="https://… or /path on this site"
value={serviceLinkUrl}
onChange={(e) => setServiceLinkUrl(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-500">
Shown as &quot;Learn More&quot; on the landing page. Leave empty to disable the link.
</p>
</div>
2026-04-10 07:21:29 +00:00
<div>
2026-04-10 08:18:11 +00:00
<label className="text-sm font-medium text-slate-700">Banner image</label>
{contributorMode ? (
<Input
className="mt-2"
type="url"
placeholder="Image URL from Media Library"
value={serviceRemoteUrl}
onChange={(e) => setServiceRemoteUrl(e.target.value)}
/>
) : (
<Input
className="mt-2 cursor-pointer"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, serviceBlobUrlRef, setServicePendingFile);
e.target.value = "";
}}
/>
)}
2026-04-10 08:18:11 +00:00
{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}
2026-04-10 07:21:29 +00:00
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => {
setServiceModalOpen(false);
beginEditService(null);
}}
>
Cancel
</Button>
<Button type="button" onClick={saveServiceDraft} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</fieldset>
2026-04-10 07:21:29 +00:00
</TabsContent>
<TabsContent value="partners" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
2026-04-10 07:21:29 +00:00
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p className="text-sm text-slate-600">
Manage technology partner listings on the homepage.
</p>
<Button
type="button"
className="shrink-0 rounded-lg bg-blue-600 hover:bg-blue-700"
onClick={openPartnerModalCreate}
>
<Plus className="mr-2 h-4 w-4" />
Add Partner
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{partners.map((p) => {
const img = p.image_url;
return (
<div
key={p.id}
className={`flex flex-col overflow-hidden rounded-xl border bg-white shadow-sm ${
partnerModalOpen && editingPartnerId === p.id
? "ring-2 ring-blue-500"
: ""
}`}
>
<button
type="button"
onClick={() => openPartnerModalEdit(p)}
className="relative flex aspect-[4/3] w-full items-center justify-center bg-slate-100"
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={img}
alt=""
className="max-h-full max-w-full object-contain p-4"
/>
) : (
<ImageIcon className="h-10 w-10 text-slate-400" />
)}
</button>
<div className="flex flex-1 flex-col p-4">
<h3 className="line-clamp-2 font-semibold text-slate-900">
{p.primary_title || "Untitled"}
</h3>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex-1"
onClick={() => openPartnerModalEdit(p)}
>
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600"
onClick={() => removePartner(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
{partners.length === 0 ? (
<p className="text-center text-sm text-slate-500">No partners yet</p>
) : null}
<Dialog
open={partnerModalOpen}
onOpenChange={(open) => {
setPartnerModalOpen(open);
if (!open) beginEditPartner(null);
}}
>
<DialogContent className="max-h-[min(90vh,560px)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{editingPartnerId ? "Edit partner" : "Add Partner"}
</DialogTitle>
<DialogDescription>
2026-04-10 08:18:11 +00:00
Partner name and logo are shown on the homepage technology partners section. Logo is stored in MinIO.
2026-04-10 07:21:29 +00:00
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div>
<label className="text-sm font-medium text-slate-700">Partner name</label>
<Input
className="mt-2"
value={partnerTitle}
onChange={(e) => setPartnerTitle(e.target.value)}
/>
</div>
<div>
2026-04-10 08:18:11 +00:00
<label className="text-sm font-medium text-slate-700">Logo image</label>
{contributorMode ? (
<Input
className="mt-2"
type="url"
placeholder="Logo URL from Media Library"
value={partnerRemoteUrl}
onChange={(e) => setPartnerRemoteUrl(e.target.value)}
/>
) : (
<Input
className="mt-2 cursor-pointer"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
2026-04-10 08:18:11 +00:00
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, partnerBlobUrlRef, setPartnerPendingFile);
e.target.value = "";
}}
2026-04-10 07:21:29 +00:00
/>
)}
2026-04-10 08:18:11 +00:00
{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}
2026-04-10 07:21:29 +00:00
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => {
setPartnerModalOpen(false);
beginEditPartner(null);
}}
>
Cancel
</Button>
<Button type="button" onClick={savePartnerRow} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</fieldset>
2026-04-10 07:21:29 +00:00
</TabsContent>
<TabsContent value="popup" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
2026-04-10 07:21:29 +00:00
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p className="text-sm text-slate-600">Manage pop ups on the homepage.</p>
<Button
type="button"
className="shrink-0 rounded-lg bg-blue-600 hover:bg-blue-700"
onClick={openPopupModalCreate}
>
<Plus className="mr-2 h-4 w-4" />
Add Pop Up
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{popups.map((p) => {
const thumb = p.images?.[0]?.media_url;
return (
<div
key={p.id}
className={`flex flex-col overflow-hidden rounded-xl border bg-white shadow-sm ${
popupModalOpen && popupEditId === p.id
? "ring-2 ring-blue-500"
: ""
}`}
>
<button
type="button"
onClick={() => openPopupModalEdit(p)}
className="relative aspect-[5/3] w-full bg-slate-100"
>
{thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumb} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-slate-400">
<ImageIcon className="h-10 w-10" />
</div>
)}
</button>
<div className="flex flex-1 flex-col p-4">
<h3 className="line-clamp-2 font-semibold text-slate-900">
{p.primary_title || "Untitled"}
</h3>
<p className="mt-1 line-clamp-2 text-sm text-slate-600">
{p.description || "—"}
</p>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex-1"
onClick={() => openPopupModalEdit(p)}
>
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600"
onClick={() => removePopup(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
<Dialog
open={popupModalOpen}
onOpenChange={(open) => {
setPopupModalOpen(open);
if (!open) beginEditPopup(null);
}}
>
<DialogContent className="max-h-[min(90vh,720px)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{popupEditId != null ? "Edit pop up" : "Add Pop Up"}
</DialogTitle>
<DialogDescription>
Content appears in the homepage news pop-up banner.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="text-sm font-medium text-slate-700">Main Title</label>
<Input
className="mt-2"
value={popupPrimary}
onChange={(e) => setPopupPrimary(e.target.value)}
/>
</div>
<div className="sm:col-span-2">
<label className="text-sm font-medium text-slate-700">Subtitle</label>
<Input
className="mt-2"
value={popupSecondary}
onChange={(e) => setPopupSecondary(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Description</label>
<Textarea
className="mt-2 min-h-[100px]"
value={popupDesc}
onChange={(e) => setPopupDesc(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-700">Primary CTA</label>
<Input
className="mt-2"
value={popupCta1}
onChange={(e) => setPopupCta1(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700">Secondary CTA</label>
<Input
className="mt-2"
value={popupCta2}
onChange={(e) => setPopupCta2(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700">
2026-04-10 08:18:11 +00:00
Banner image (optional)
2026-04-10 07:21:29 +00:00
</label>
{contributorMode ? (
<Input
className="mt-2"
type="url"
placeholder="Image URL from Media Library"
value={popupRemoteUrl}
onChange={(e) => setPopupRemoteUrl(e.target.value)}
/>
) : (
<Input
className="mt-2 cursor-pointer"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={(e) => {
const f = e.target.files?.[0] ?? null;
setPickedFile(f, popupBlobUrlRef, setPopupPendingFile);
e.target.value = "";
}}
/>
)}
2026-04-10 08:18:11 +00:00
{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}
2026-04-10 07:21:29 +00:00
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => {
setPopupModalOpen(false);
beginEditPopup(null);
}}
>
Cancel
</Button>
<Button type="button" onClick={savePopupDraft} disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</fieldset>
2026-04-10 07:21:29 +00:00
</TabsContent>
</Tabs>
</div>
2026-02-17 10:02:35 +00:00
</div>
);
}