1816 lines
68 KiB
TypeScript
1816 lines
68 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type MutableRefObject,
|
|
} from "react";
|
|
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";
|
|
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,
|
|
saveAboutUsMediaUpload,
|
|
saveHeroContent,
|
|
saveHeroImageUpload,
|
|
saveOurProductContent,
|
|
saveOurServiceContent,
|
|
savePartnerContent,
|
|
saveOurProductImageUpload,
|
|
saveOurServiceImageUpload,
|
|
savePopupNews,
|
|
savePopupNewsImageUpload,
|
|
updateAboutContent,
|
|
updateHeroContent,
|
|
updateHeroImageUpload,
|
|
updateOurProductContent,
|
|
updateOurProductImageUpload,
|
|
updateOurServiceContent,
|
|
updateOurServiceImageUpload,
|
|
updatePartnerContent,
|
|
updatePopupNews,
|
|
uploadPartnerLogo,
|
|
} 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() {
|
|
const [activeTab, setActiveTab] = useState("hero");
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
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("");
|
|
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 [aboutPrimary, setAboutPrimary] = useState("");
|
|
const [aboutSecondary, setAboutSecondary] = useState("");
|
|
const [aboutDesc, setAboutDesc] = useState("");
|
|
const [aboutCta1, setAboutCta1] = useState("");
|
|
const [aboutCta2, setAboutCta2] = useState("");
|
|
const [aboutRemoteMediaUrl, setAboutRemoteMediaUrl] = useState("");
|
|
const [aboutPendingFile, setAboutPendingFile] = useState<File | null>(null);
|
|
const aboutBlobUrlRef = useRef<string | null>(null);
|
|
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("");
|
|
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 [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("");
|
|
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 [serviceModalOpen, setServiceModalOpen] = useState(false);
|
|
|
|
const [partners, setPartners] = useState<CmsPartnerContent[]>([]);
|
|
const [partnerTitle, setPartnerTitle] = 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 [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("");
|
|
const [popupRemoteUrl, setPopupRemoteUrl] = useState("");
|
|
const [popupPendingFile, setPopupPendingFile] = useState<File | null>(null);
|
|
const popupBlobUrlRef = useRef<string | null>(null);
|
|
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];
|
|
revokeBlobRef(heroBlobUrlRef);
|
|
setHeroPendingFile(null);
|
|
if (first?.image_url) {
|
|
setHeroRemoteUrl(first.image_url);
|
|
setHeroImageId(first.id ?? null);
|
|
} else {
|
|
setHeroRemoteUrl("");
|
|
setHeroImageId(null);
|
|
}
|
|
} else {
|
|
setHeroId(null);
|
|
setHeroImageId(null);
|
|
setHeroPrimary("");
|
|
setHeroSecondary("");
|
|
setHeroDesc("");
|
|
setHeroCta1("");
|
|
setHeroCta2("");
|
|
revokeBlobRef(heroBlobUrlRef);
|
|
setHeroPendingFile(null);
|
|
setHeroRemoteUrl("");
|
|
}
|
|
|
|
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() ?? "";
|
|
revokeBlobRef(aboutBlobUrlRef);
|
|
setAboutPendingFile(null);
|
|
setAboutRemoteMediaUrl(murl);
|
|
setAboutMediaImageId(am?.id ?? null);
|
|
} else {
|
|
setAboutId(null);
|
|
setAboutPrimary("");
|
|
setAboutSecondary("");
|
|
setAboutDesc("");
|
|
setAboutCta1("");
|
|
setAboutCta2("");
|
|
revokeBlobRef(aboutBlobUrlRef);
|
|
setAboutPendingFile(null);
|
|
setAboutRemoteMediaUrl("");
|
|
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]);
|
|
|
|
async function saveHeroTab() {
|
|
if (!heroPrimary.trim()) {
|
|
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
if (heroPendingFile && hid) {
|
|
const fd = new FormData();
|
|
fd.append("file", heroPendingFile);
|
|
let imgRes;
|
|
if (heroImageId) {
|
|
imgRes = await updateHeroImageUpload(heroImageId, fd);
|
|
} else {
|
|
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 ?? ""),
|
|
});
|
|
return;
|
|
}
|
|
revokeBlobRef(heroBlobUrlRef);
|
|
setHeroPendingFile(null);
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 ?? ""),
|
|
});
|
|
return;
|
|
}
|
|
revokeBlobRef(aboutBlobUrlRef);
|
|
setAboutPendingFile(null);
|
|
}
|
|
|
|
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("");
|
|
revokeBlobRef(productBlobUrlRef);
|
|
setProductPendingFile(null);
|
|
setProductRemoteUrl("");
|
|
setProductImageId(null);
|
|
return;
|
|
}
|
|
setProductEditId(p.id);
|
|
setProductPrimary(p.primary_title ?? "");
|
|
setProductSecondary(p.secondary_title ?? "");
|
|
setProductDesc(p.description ?? "");
|
|
setProductLinkUrl(p.link_url ?? "");
|
|
const im = p.images?.[0];
|
|
revokeBlobRef(productBlobUrlRef);
|
|
setProductPendingFile(null);
|
|
setProductRemoteUrl(im?.image_url?.trim() ?? "");
|
|
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;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const body = {
|
|
primary_title: productPrimary,
|
|
secondary_title: productSecondary,
|
|
description: productDesc,
|
|
link_url: productLinkUrl,
|
|
};
|
|
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;
|
|
}
|
|
}
|
|
if (productPendingFile && pid) {
|
|
const fd = new FormData();
|
|
fd.append("file", productPendingFile);
|
|
let ires;
|
|
if (productImageId) {
|
|
ires = await updateOurProductImageUpload(productImageId, fd);
|
|
} else {
|
|
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 ?? ""),
|
|
});
|
|
return;
|
|
}
|
|
revokeBlobRef(productBlobUrlRef);
|
|
setProductPendingFile(null);
|
|
}
|
|
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;
|
|
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("");
|
|
revokeBlobRef(serviceBlobUrlRef);
|
|
setServicePendingFile(null);
|
|
setServiceRemoteUrl("");
|
|
setServiceImageId(null);
|
|
return;
|
|
}
|
|
setServiceEditId(s.id);
|
|
setServicePrimary(s.primary_title ?? "");
|
|
setServiceSecondary(s.secondary_title ?? "");
|
|
setServiceDesc(s.description ?? "");
|
|
setServiceLinkUrl(s.link_url ?? "");
|
|
const im = s.images?.[0];
|
|
revokeBlobRef(serviceBlobUrlRef);
|
|
setServicePendingFile(null);
|
|
setServiceRemoteUrl(im?.image_url?.trim() ?? "");
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
if (servicePendingFile && sid != null) {
|
|
const fd = new FormData();
|
|
fd.append("file", servicePendingFile);
|
|
let ires;
|
|
if (serviceImageId) {
|
|
ires = await updateOurServiceImageUpload(serviceImageId, fd);
|
|
} else {
|
|
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 ?? ""),
|
|
});
|
|
return;
|
|
}
|
|
revokeBlobRef(serviceBlobUrlRef);
|
|
setServicePendingFile(null);
|
|
}
|
|
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;
|
|
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("");
|
|
revokeBlobRef(partnerBlobUrlRef);
|
|
setPartnerPendingFile(null);
|
|
setPartnerRemoteUrl("");
|
|
setPartnerStoredPath("");
|
|
return;
|
|
}
|
|
setEditingPartnerId(p.id);
|
|
setPartnerTitle(p.primary_title ?? "");
|
|
revokeBlobRef(partnerBlobUrlRef);
|
|
setPartnerPendingFile(null);
|
|
setPartnerRemoteUrl(p.image_url ?? "");
|
|
setPartnerStoredPath(p.image_path ?? "");
|
|
}
|
|
|
|
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;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const body = {
|
|
primary_title: partnerTitle.trim(),
|
|
};
|
|
let partnerId = editingPartnerId;
|
|
if (editingPartnerId) {
|
|
const res = await updatePartnerContent(editingPartnerId, {
|
|
primary_title: body.primary_title,
|
|
image_path: partnerStoredPath,
|
|
image_url: partnerRemoteUrl,
|
|
});
|
|
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;
|
|
}
|
|
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);
|
|
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;
|
|
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("");
|
|
revokeBlobRef(popupBlobUrlRef);
|
|
setPopupPendingFile(null);
|
|
setPopupRemoteUrl("");
|
|
return;
|
|
}
|
|
setPopupEditId(p.id);
|
|
setPopupPrimary(p.primary_title ?? "");
|
|
setPopupSecondary(p.secondary_title ?? "");
|
|
setPopupDesc(p.description ?? "");
|
|
setPopupCta1(p.primary_cta ?? "");
|
|
setPopupCta2(p.secondary_cta_text ?? "");
|
|
revokeBlobRef(popupBlobUrlRef);
|
|
setPopupPendingFile(null);
|
|
setPopupRemoteUrl(p.images?.[0]?.media_url?.trim() ?? "");
|
|
}
|
|
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
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;
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
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;
|
|
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;
|
|
}
|
|
if (popupEditId === id) {
|
|
beginEditPopup(null);
|
|
setPopupModalOpen(false);
|
|
}
|
|
await loadAll();
|
|
await Swal.fire({ icon: "success", title: "Deleted", timer: 1200, showConfirmButton: false });
|
|
} 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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<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">
|
|
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>
|
|
<Button type="button" className="rounded-lg bg-blue-600 hover:bg-blue-700" disabled>
|
|
<Pencil className="mr-2 h-4 w-4" />
|
|
Edit Mode
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="h-auto flex-wrap rounded-xl border bg-white p-1">
|
|
<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>
|
|
|
|
<TabsContent value="hero" className="mt-4">
|
|
<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>
|
|
<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>
|
|
<Input
|
|
className="mt-2"
|
|
value={heroSecondary}
|
|
onChange={(e) => setHeroSecondary(e.target.value)}
|
|
placeholder=""
|
|
/>
|
|
</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>
|
|
<Textarea
|
|
className="mt-2 min-h-[120px]"
|
|
value={heroDesc}
|
|
onChange={(e) => setHeroDesc(e.target.value)}
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-700">Hero image</label>
|
|
<p className="mb-2 text-xs text-slate-500">
|
|
Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero.
|
|
</p>
|
|
<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 = "";
|
|
}}
|
|
/>
|
|
{heroBlobUrlRef.current || heroRemoteUrl ? (
|
|
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={heroBlobUrlRef.current || heroRemoteUrl}
|
|
alt=""
|
|
className="max-h-48 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" />
|
|
<p className="text-sm text-slate-500">No hero image yet</p>
|
|
</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>
|
|
<Input
|
|
className="mt-2"
|
|
value={heroCta1}
|
|
onChange={(e) => setHeroCta1(e.target.value)}
|
|
placeholder=""
|
|
/>
|
|
</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>
|
|
<Input
|
|
className="mt-2"
|
|
value={heroCta2}
|
|
onChange={(e) => setHeroCta2(e.target.value)}
|
|
placeholder=""
|
|
/>
|
|
</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>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="about" className="mt-4">
|
|
<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>
|
|
<label className="text-sm font-medium text-slate-700">Media (image or video)</label>
|
|
<p className="mb-2 text-xs text-slate-500">
|
|
Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page.
|
|
</p>
|
|
<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 = "";
|
|
}}
|
|
/>
|
|
{aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
|
|
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
|
{aboutPendingFile?.type.startsWith("video/") ||
|
|
/\.(mp4|webm)(\?|$)/i.test(aboutRemoteMediaUrl) ? (
|
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
<video
|
|
src={aboutBlobUrlRef.current || aboutRemoteMediaUrl}
|
|
controls
|
|
className="max-h-56 max-w-full rounded-lg"
|
|
playsInline
|
|
/>
|
|
) : (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={aboutBlobUrlRef.current || aboutRemoteMediaUrl}
|
|
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" />
|
|
<p className="text-sm text-slate-500">No media yet</p>
|
|
</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 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>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="products" className="mt-4">
|
|
<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 "Label: detail" formats the label in bold. Product link powers the Learn
|
|
More action.
|
|
</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 "Learn More" on the landing page. Leave empty to disable the link.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-700">Card image</label>
|
|
<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 = "";
|
|
}}
|
|
/>
|
|
{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>
|
|
<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>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="services" className="mt-4">
|
|
<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 "Label: detail" bolds the label. Service link powers Learn
|
|
More.
|
|
</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 "Learn More" on the landing page. Leave empty to disable the link.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-700">Banner image</label>
|
|
<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 = "";
|
|
}}
|
|
/>
|
|
{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>
|
|
<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>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="partners" className="mt-4">
|
|
<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>
|
|
Partner name and logo are shown on the homepage technology partners section. Logo is stored in MinIO.
|
|
</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>
|
|
<label className="text-sm font-medium text-slate-700">Logo image</label>
|
|
<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, 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>
|
|
<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>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="popup" className="mt-4">
|
|
<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">
|
|
Banner image (optional)
|
|
</label>
|
|
<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 = "";
|
|
}}
|
|
/>
|
|
{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>
|
|
<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>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|