feat: update content website for approver
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
9a91a4ff7b
commit
7515f55e88
|
|
@ -33,7 +33,11 @@ export default function ContentWebsitePage() {
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{levelId === "2" ? <ApproverContentWebsite /> : <ContentWebsite />}
|
{levelId === "3" ? (
|
||||||
|
<ApproverContentWebsite />
|
||||||
|
) : (
|
||||||
|
<ContentWebsite contributorMode={levelId === "2"} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,250 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Eye, Pencil, Trash2, Filter } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Filter, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
approveCmsContentSubmission,
|
||||||
|
listCmsContentSubmissions,
|
||||||
|
rejectCmsContentSubmission,
|
||||||
|
} from "@/service/cms-content-submissions";
|
||||||
|
import { apiPayload } from "@/service/cms-landing";
|
||||||
|
import { formatDate } from "@/utils/global";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
const DOMAIN_LABEL: Record<string, string> = {
|
||||||
|
hero: "Hero",
|
||||||
|
about: "About Us",
|
||||||
|
product: "Product",
|
||||||
|
service: "Service",
|
||||||
|
partner: "Partner",
|
||||||
|
popup: "Pop Up",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
submitter_name: string;
|
||||||
|
submitted_by_id: number;
|
||||||
|
payload: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ApproverContentWebsite() {
|
export default function ApproverContentWebsite() {
|
||||||
const tabs = [
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
"Hero Section",
|
const [loading, setLoading] = useState(true);
|
||||||
"About Us",
|
const [search, setSearch] = useState("");
|
||||||
"Our Products",
|
const [actingId, setActingId] = useState<string | null>(null);
|
||||||
"Our Services",
|
|
||||||
"Technology Partners",
|
|
||||||
"Pop Up",
|
|
||||||
];
|
|
||||||
|
|
||||||
const data = [
|
const load = useCallback(async () => {
|
||||||
{
|
setLoading(true);
|
||||||
title: "Beyond Expectations to Build Reputation.",
|
try {
|
||||||
subtitle: "-",
|
const res = await listCmsContentSubmissions({
|
||||||
author: "John Kontributor",
|
status: "pending",
|
||||||
status: "Published",
|
page: 1,
|
||||||
date: "2024-01-15",
|
limit: 100,
|
||||||
},
|
});
|
||||||
{
|
const raw = apiPayload<Row[]>(res);
|
||||||
title: "Manajemen Reputasi untuk Institusi",
|
setRows(Array.isArray(raw) ? raw : []);
|
||||||
subtitle: "-",
|
} finally {
|
||||||
author: "Sarah Kontributor",
|
setLoading(false);
|
||||||
status: "Pending",
|
}
|
||||||
date: "2024-01-14",
|
}, []);
|
||||||
},
|
|
||||||
];
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const filtered = rows.filter((r) => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
r.title.toLowerCase().includes(q) ||
|
||||||
|
(r.submitter_name ?? "").toLowerCase().includes(q) ||
|
||||||
|
(DOMAIN_LABEL[r.domain] ?? r.domain).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onApprove(id: string) {
|
||||||
|
const ok = await Swal.fire({
|
||||||
|
icon: "question",
|
||||||
|
title: "Terapkan perubahan ke website?",
|
||||||
|
showCancelButton: true,
|
||||||
|
});
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
setActingId(id);
|
||||||
|
try {
|
||||||
|
const res = await approveCmsContentSubmission(id);
|
||||||
|
if ((res as { error?: boolean })?.error) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "error",
|
||||||
|
title: "Gagal",
|
||||||
|
text: String((res as { message?: unknown })?.message ?? ""),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "success",
|
||||||
|
title: "Disetujui",
|
||||||
|
timer: 1600,
|
||||||
|
showConfirmButton: false,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setActingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReject(id: string) {
|
||||||
|
const note = await Swal.fire({
|
||||||
|
icon: "warning",
|
||||||
|
title: "Tolak pengajuan?",
|
||||||
|
input: "textarea",
|
||||||
|
inputPlaceholder: "Catatan (opsional)",
|
||||||
|
showCancelButton: true,
|
||||||
|
});
|
||||||
|
if (!note.isConfirmed) return;
|
||||||
|
setActingId(id);
|
||||||
|
try {
|
||||||
|
const res = await rejectCmsContentSubmission(
|
||||||
|
id,
|
||||||
|
typeof note.value === "string" ? note.value : "",
|
||||||
|
);
|
||||||
|
if ((res as { error?: boolean })?.error) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "error",
|
||||||
|
title: "Gagal",
|
||||||
|
text: String((res as { message?: unknown })?.message ?? ""),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "success",
|
||||||
|
title: "Ditolak",
|
||||||
|
timer: 1400,
|
||||||
|
showConfirmButton: false,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setActingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* HEADER */}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-800">Content Website</h1>
|
<h1 className="text-2xl font-bold text-slate-800">Content Website</h1>
|
||||||
<p className="text-slate-500">
|
<p className="text-slate-500">
|
||||||
Update homepage content, products, services, and partners
|
Tinjau pengajuan perubahan dari kontributor dan terapkan ke konten live.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TABS */}
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="bg-white rounded-2xl shadow border p-2 flex flex-wrap gap-2">
|
<Input
|
||||||
{tabs.map((tab, i) => (
|
placeholder="Cari judul, domain, atau nama pengaju…"
|
||||||
<button
|
value={search}
|
||||||
key={i}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition ${
|
className="max-w-md"
|
||||||
i === 0
|
/>
|
||||||
? "bg-blue-600 text-white"
|
<Button
|
||||||
: "hover:bg-slate-100 text-slate-600"
|
type="button"
|
||||||
}`}
|
variant="outline"
|
||||||
>
|
className="gap-2 shrink-0"
|
||||||
{tab}
|
onClick={() => load()}
|
||||||
</button>
|
disabled={loading}
|
||||||
))}
|
>
|
||||||
</div>
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{/* SEARCH & FILTER */}
|
) : (
|
||||||
<div className="flex gap-4">
|
<Filter className="h-4 w-4" />
|
||||||
<Input placeholder="Search Hero Section by title, author, or content..." />
|
)}
|
||||||
<Button variant="outline" className="flex items-center gap-2">
|
Refresh
|
||||||
<Filter size={16} /> Filters
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TABLE */}
|
<Card className="rounded-2xl border shadow-sm overflow-hidden">
|
||||||
<div className="bg-white rounded-2xl shadow border overflow-hidden">
|
<CardContent className="p-0">
|
||||||
<table className="w-full text-sm">
|
{loading ? (
|
||||||
<thead className="bg-slate-50 text-slate-600">
|
<div className="flex justify-center py-16">
|
||||||
<tr>
|
<Loader2 className="h-10 w-10 animate-spin text-[#966314]" />
|
||||||
<th className="text-left px-6 py-4">Main Title</th>
|
</div>
|
||||||
<th className="text-left px-6 py-4">Subtitle</th>
|
) : (
|
||||||
<th className="text-left px-6 py-4">Author</th>
|
<Table>
|
||||||
<th className="text-left px-6 py-4">Status</th>
|
<TableHeader>
|
||||||
<th className="text-left px-6 py-4">Date</th>
|
<TableRow className="bg-slate-50">
|
||||||
<th className="text-left px-6 py-4">Actions</th>
|
<TableHead>Judul</TableHead>
|
||||||
</tr>
|
<TableHead>Bagian</TableHead>
|
||||||
</thead>
|
<TableHead>Pengaju</TableHead>
|
||||||
|
<TableHead>Tanggal</TableHead>
|
||||||
<tbody>
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
{data.map((item, i) => (
|
</TableRow>
|
||||||
<tr key={i} className="border-t hover:bg-slate-50 transition">
|
</TableHeader>
|
||||||
<td className="px-6 py-4 font-medium text-slate-800">
|
<TableBody>
|
||||||
{item.title}
|
{filtered.length === 0 ? (
|
||||||
</td>
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-slate-500 py-10">
|
||||||
<td className="px-6 py-4">{item.subtitle}</td>
|
Tidak ada pengajuan tertunda.
|
||||||
|
</TableCell>
|
||||||
<td className="px-6 py-4 text-slate-600">{item.author}</td>
|
</TableRow>
|
||||||
|
) : (
|
||||||
<td className="px-6 py-4">
|
filtered.map((item) => (
|
||||||
<span
|
<TableRow key={item.id}>
|
||||||
className={`text-xs font-medium px-3 py-1 rounded-full ${
|
<TableCell className="font-medium max-w-xs">
|
||||||
item.status === "Published"
|
{item.title}
|
||||||
? "bg-green-100 text-green-600"
|
</TableCell>
|
||||||
: "bg-yellow-100 text-yellow-600"
|
<TableCell>
|
||||||
}`}
|
<Badge variant="outline">
|
||||||
>
|
{DOMAIN_LABEL[item.domain] ?? item.domain}
|
||||||
{item.status}
|
</Badge>
|
||||||
</span>
|
</TableCell>
|
||||||
</td>
|
<TableCell className="text-slate-600">
|
||||||
|
{item.submitter_name || `User #${item.submitted_by_id}`}
|
||||||
<td className="px-6 py-4 text-slate-600">{item.date}</td>
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-600">
|
||||||
<td className="px-6 py-4">
|
{formatDate(item.created_at)}
|
||||||
<div className="flex gap-3 text-slate-500">
|
</TableCell>
|
||||||
<Eye
|
<TableCell className="text-right space-x-2">
|
||||||
size={18}
|
<Button
|
||||||
className="cursor-pointer hover:text-blue-600"
|
type="button"
|
||||||
/>
|
size="sm"
|
||||||
<Pencil
|
className="bg-green-600 hover:bg-green-700"
|
||||||
size={18}
|
disabled={actingId === item.id}
|
||||||
className="cursor-pointer hover:text-green-600"
|
onClick={() => onApprove(item.id)}
|
||||||
/>
|
>
|
||||||
<Trash2
|
Setujui
|
||||||
size={18}
|
</Button>
|
||||||
className="cursor-pointer hover:text-red-600"
|
<Button
|
||||||
/>
|
type="button"
|
||||||
</div>
|
size="sm"
|
||||||
</td>
|
variant="outline"
|
||||||
</tr>
|
className="text-red-600 border-red-200"
|
||||||
))}
|
disabled={actingId === item.id}
|
||||||
</tbody>
|
onClick={() => onReject(item.id)}
|
||||||
</table>
|
>
|
||||||
|
Tolak
|
||||||
{/* FOOTER */}
|
</Button>
|
||||||
<div className="flex justify-between items-center px-6 py-4 border-t bg-slate-50">
|
</TableCell>
|
||||||
<p className="text-sm text-slate-500">Showing 1 to 2 of 2 items</p>
|
</TableRow>
|
||||||
|
))
|
||||||
<div className="flex gap-2">
|
)}
|
||||||
<button className="px-4 py-2 border rounded-lg text-sm">
|
</TableBody>
|
||||||
Previous
|
</Table>
|
||||||
</button>
|
)}
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">
|
</CardContent>
|
||||||
1
|
</Card>
|
||||||
</button>
|
|
||||||
<button className="px-4 py-2 border rounded-lg text-sm">2</button>
|
|
||||||
<button className="px-4 py-2 border rounded-lg text-sm">3</button>
|
|
||||||
<button className="px-4 py-2 border rounded-lg text-sm">
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import {
|
||||||
updatePopupNews,
|
updatePopupNews,
|
||||||
uploadPartnerLogo,
|
uploadPartnerLogo,
|
||||||
} from "@/service/cms-landing";
|
} from "@/service/cms-landing";
|
||||||
|
import { submitCmsContentSubmission } from "@/service/cms-content-submissions";
|
||||||
|
|
||||||
function revokeBlobRef(ref: MutableRefObject<string | null>) {
|
function revokeBlobRef(ref: MutableRefObject<string | null>) {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
|
|
@ -87,10 +88,19 @@ function setPickedFile(
|
||||||
setter(file);
|
setter(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContentWebsite() {
|
type ContentWebsiteProps = {
|
||||||
|
/** User level 2: changes go through approval instead of live CMS APIs. */
|
||||||
|
contributorMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContentWebsite({
|
||||||
|
contributorMode = false,
|
||||||
|
}: ContentWebsiteProps) {
|
||||||
const [activeTab, setActiveTab] = useState("hero");
|
const [activeTab, setActiveTab] = useState("hero");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(!contributorMode);
|
||||||
|
const canInteract = !contributorMode || editMode;
|
||||||
|
|
||||||
const [heroId, setHeroId] = useState<string | null>(null);
|
const [heroId, setHeroId] = useState<string | null>(null);
|
||||||
const [heroImageId, setHeroImageId] = useState<string | null>(null);
|
const [heroImageId, setHeroImageId] = useState<string | null>(null);
|
||||||
|
|
@ -257,6 +267,51 @@ export default function ContentWebsite() {
|
||||||
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -316,6 +371,50 @@ export default function ContentWebsite() {
|
||||||
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = {
|
const body: Record<string, string> = {
|
||||||
|
|
@ -410,6 +509,51 @@ export default function ContentWebsite() {
|
||||||
await Swal.fire({ icon: "warning", title: "Product title is required" });
|
await Swal.fire({ icon: "warning", title: "Product title is required" });
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -471,6 +615,38 @@ export default function ContentWebsite() {
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
});
|
});
|
||||||
if (!ok.isConfirmed) return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await deleteOurProductContent(id);
|
await deleteOurProductContent(id);
|
||||||
|
|
@ -524,6 +700,51 @@ export default function ContentWebsite() {
|
||||||
await Swal.fire({ icon: "warning", title: "Service title is required" });
|
await Swal.fire({ icon: "warning", title: "Service title is required" });
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -584,6 +805,38 @@ export default function ContentWebsite() {
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
});
|
});
|
||||||
if (!ok.isConfirmed) return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await deleteOurServiceContent(id);
|
await deleteOurServiceContent(id);
|
||||||
|
|
@ -630,6 +883,48 @@ export default function ContentWebsite() {
|
||||||
await Swal.fire({ icon: "warning", title: "Partner name is required" });
|
await Swal.fire({ icon: "warning", title: "Partner name is required" });
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -686,6 +981,38 @@ export default function ContentWebsite() {
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
});
|
});
|
||||||
if (!ok.isConfirmed) return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await deletePartnerContent(id);
|
await deletePartnerContent(id);
|
||||||
|
|
@ -738,6 +1065,51 @@ export default function ContentWebsite() {
|
||||||
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
await Swal.fire({ icon: "warning", title: "Main title is required" });
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
|
|
@ -803,6 +1175,38 @@ export default function ContentWebsite() {
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
});
|
});
|
||||||
if (!ok.isConfirmed) return;
|
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;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await deletePopupNews(id);
|
const res = await deletePopupNews(id);
|
||||||
|
|
@ -852,13 +1256,32 @@ export default function ContentWebsite() {
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" className="rounded-lg bg-blue-600 hover:bg-blue-700" disabled>
|
{contributorMode ? (
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Button
|
||||||
Edit Mode
|
type="button"
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contributorMode && !editMode ? (
|
||||||
|
<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={
|
||||||
|
canInteract ? "" : "pointer-events-none select-none opacity-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="h-auto flex-wrap rounded-xl border bg-white p-1">
|
<TabsList className="h-auto flex-wrap rounded-xl border bg-white p-1">
|
||||||
<TabsTrigger value="hero" className="rounded-lg">
|
<TabsTrigger value="hero" className="rounded-lg">
|
||||||
|
|
@ -922,18 +1345,30 @@ export default function ContentWebsite() {
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-slate-700">Hero image</label>
|
<label className="text-sm font-medium text-slate-700">Hero image</label>
|
||||||
<p className="mb-2 text-xs text-slate-500">
|
<p className="mb-2 text-xs text-slate-500">
|
||||||
Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero.
|
{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."}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
{contributorMode ? (
|
||||||
className="mt-1 cursor-pointer"
|
<Input
|
||||||
type="file"
|
className="mt-1"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
type="url"
|
||||||
onChange={(e) => {
|
placeholder="https://…"
|
||||||
const f = e.target.files?.[0] ?? null;
|
value={heroRemoteUrl}
|
||||||
setPickedFile(f, heroBlobUrlRef, setHeroPendingFile);
|
onChange={(e) => setHeroRemoteUrl(e.target.value)}
|
||||||
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{heroBlobUrlRef.current || heroRemoteUrl ? (
|
{heroBlobUrlRef.current || heroRemoteUrl ? (
|
||||||
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|
@ -1015,18 +1450,30 @@ export default function ContentWebsite() {
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-slate-700">Media (image or video)</label>
|
<label className="text-sm font-medium text-slate-700">Media (image or video)</label>
|
||||||
<p className="mb-2 text-xs text-slate-500">
|
<p className="mb-2 text-xs text-slate-500">
|
||||||
Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page.
|
{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."}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
{contributorMode ? (
|
||||||
className="mt-1 cursor-pointer"
|
<Input
|
||||||
type="file"
|
className="mt-1"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm"
|
type="url"
|
||||||
onChange={(e) => {
|
placeholder="https://…"
|
||||||
const f = e.target.files?.[0] ?? null;
|
value={aboutRemoteMediaUrl}
|
||||||
setPickedFile(f, aboutBlobUrlRef, setAboutPendingFile);
|
onChange={(e) => setAboutRemoteMediaUrl(e.target.value)}
|
||||||
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
|
{aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
|
||||||
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
<div className="mt-4 flex justify-center rounded-xl border bg-slate-50 p-6">
|
||||||
{aboutPendingFile?.type.startsWith("video/") ||
|
{aboutPendingFile?.type.startsWith("video/") ||
|
||||||
|
|
@ -1253,16 +1700,26 @@ export default function ContentWebsite() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-slate-700">Card image</label>
|
<label className="text-sm font-medium text-slate-700">Card image</label>
|
||||||
<Input
|
{contributorMode ? (
|
||||||
className="mt-2 cursor-pointer"
|
<Input
|
||||||
type="file"
|
className="mt-2"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
type="url"
|
||||||
onChange={(e) => {
|
placeholder="Image URL from Media Library"
|
||||||
const f = e.target.files?.[0] ?? null;
|
value={productRemoteUrl}
|
||||||
setPickedFile(f, productBlobUrlRef, setProductPendingFile);
|
onChange={(e) => setProductRemoteUrl(e.target.value)}
|
||||||
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{productBlobUrlRef.current || productRemoteUrl ? (
|
{productBlobUrlRef.current || productRemoteUrl ? (
|
||||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|
@ -1433,16 +1890,26 @@ export default function ContentWebsite() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-slate-700">Banner image</label>
|
<label className="text-sm font-medium text-slate-700">Banner image</label>
|
||||||
<Input
|
{contributorMode ? (
|
||||||
className="mt-2 cursor-pointer"
|
<Input
|
||||||
type="file"
|
className="mt-2"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
type="url"
|
||||||
onChange={(e) => {
|
placeholder="Image URL from Media Library"
|
||||||
const f = e.target.files?.[0] ?? null;
|
value={serviceRemoteUrl}
|
||||||
setPickedFile(f, serviceBlobUrlRef, setServicePendingFile);
|
onChange={(e) => setServiceRemoteUrl(e.target.value)}
|
||||||
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{serviceBlobUrlRef.current || serviceRemoteUrl ? (
|
{serviceBlobUrlRef.current || serviceRemoteUrl ? (
|
||||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|
@ -1583,16 +2050,26 @@ export default function ContentWebsite() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-slate-700">Logo image</label>
|
<label className="text-sm font-medium text-slate-700">Logo image</label>
|
||||||
<Input
|
{contributorMode ? (
|
||||||
className="mt-2 cursor-pointer"
|
<Input
|
||||||
type="file"
|
className="mt-2"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
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"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const f = e.target.files?.[0] ?? null;
|
const f = e.target.files?.[0] ?? null;
|
||||||
setPickedFile(f, partnerBlobUrlRef, setPartnerPendingFile);
|
setPickedFile(f, partnerBlobUrlRef, setPartnerPendingFile);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{partnerBlobUrlRef.current || partnerRemoteUrl ? (
|
{partnerBlobUrlRef.current || partnerRemoteUrl ? (
|
||||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-4">
|
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-4">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|
@ -1766,16 +2243,26 @@ export default function ContentWebsite() {
|
||||||
<label className="text-sm font-medium text-slate-700">
|
<label className="text-sm font-medium text-slate-700">
|
||||||
Banner image (optional)
|
Banner image (optional)
|
||||||
</label>
|
</label>
|
||||||
<Input
|
{contributorMode ? (
|
||||||
className="mt-2 cursor-pointer"
|
<Input
|
||||||
type="file"
|
className="mt-2"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
type="url"
|
||||||
onChange={(e) => {
|
placeholder="Image URL from Media Library"
|
||||||
const f = e.target.files?.[0] ?? null;
|
value={popupRemoteUrl}
|
||||||
setPickedFile(f, popupBlobUrlRef, setPopupPendingFile);
|
onChange={(e) => setPopupRemoteUrl(e.target.value)}
|
||||||
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{popupBlobUrlRef.current || popupRemoteUrl ? (
|
{popupBlobUrlRef.current || popupRemoteUrl ? (
|
||||||
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
<div className="mt-3 flex justify-center rounded-lg border bg-slate-50 p-3">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
|
@ -1810,6 +2297,7 @@ export default function ContentWebsite() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,95 +4,283 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Search, Filter } from "lucide-react";
|
import { Search, Filter, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { formatDate } from "@/utils/global";
|
||||||
|
import { listCmsContentSubmissions } from "@/service/cms-content-submissions";
|
||||||
|
import { getArticlesForMyContent } from "@/service/article";
|
||||||
|
import { apiPayload } from "@/service/cms-landing";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
const stats = [
|
const PLACEHOLDER_IMG =
|
||||||
{ title: "Total Content", value: 24, color: "bg-blue-500" },
|
"https://placehold.co/400x240/f1f5f9/64748b?text=Content";
|
||||||
{ title: "Drafts", value: 8, color: "bg-slate-600" },
|
|
||||||
{ title: "Pending", value: 10, color: "bg-yellow-500" },
|
|
||||||
{ title: "Approved", value: 10, color: "bg-green-600" },
|
|
||||||
{ title: "Revision/Rejected", value: 6, color: "bg-red-600" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const contents = [
|
type CmsRow = {
|
||||||
{
|
id: string;
|
||||||
id: 1,
|
domain: string;
|
||||||
title: "Bharatu Mardi Hadji Gugur Saat Bertugas...",
|
title: string;
|
||||||
image: "/image/bharatu.jpg",
|
status: string;
|
||||||
status: "Pending",
|
submitter_name: string;
|
||||||
category: "News",
|
submitted_by_id: number;
|
||||||
date: "2024-01-20",
|
created_at: string;
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Novita Hardini: Jangan Sampai Pariwisata...",
|
|
||||||
image: "/image/novita2.png",
|
|
||||||
status: "Approved",
|
|
||||||
category: "News",
|
|
||||||
date: "2024-01-20",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Lestari Moerdijat: Butuh Afirmasi...",
|
|
||||||
image: "/image/lestari2.png",
|
|
||||||
status: "Rejected",
|
|
||||||
category: "News",
|
|
||||||
date: "2024-01-20",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Lestari Moerdijat: Butuh Afirmasi...",
|
|
||||||
image: "/image/lestari2.png",
|
|
||||||
status: "Draft",
|
|
||||||
category: "News",
|
|
||||||
date: "2024-01-20",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getStatusStyle = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Pending Approval":
|
|
||||||
return "bg-yellow-100 text-yellow-700 border border-yellow-200";
|
|
||||||
|
|
||||||
case "Approved":
|
|
||||||
return "bg-green-100 text-green-700 border border-green-200";
|
|
||||||
|
|
||||||
case "Rejected":
|
|
||||||
return "bg-red-100 text-red-700 border border-red-200";
|
|
||||||
|
|
||||||
case "Draft":
|
|
||||||
return "bg-slate-100 text-slate-600 border border-slate-200";
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ArticleRow = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
isDraft?: boolean;
|
||||||
|
isPublish?: boolean;
|
||||||
|
publishStatus?: string;
|
||||||
|
statusId?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
created_at?: string;
|
||||||
|
createdByName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedStatus = "draft" | "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
type UnifiedItem = {
|
||||||
|
key: string;
|
||||||
|
source: "website" | "news";
|
||||||
|
title: string;
|
||||||
|
thumb: string;
|
||||||
|
status: UnifiedStatus;
|
||||||
|
statusLabel: string;
|
||||||
|
date: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cmsToUnified(r: CmsRow): UnifiedItem {
|
||||||
|
const st = (r.status || "").toLowerCase();
|
||||||
|
let status: UnifiedStatus = "pending";
|
||||||
|
let statusLabel = "Pending Approval";
|
||||||
|
if (st === "approved") {
|
||||||
|
status = "approved";
|
||||||
|
statusLabel = "Approved";
|
||||||
|
} else if (st === "rejected") {
|
||||||
|
status = "rejected";
|
||||||
|
statusLabel = "Rejected";
|
||||||
|
} else if (st === "pending") {
|
||||||
|
status = "pending";
|
||||||
|
statusLabel = "Pending Approval";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `cms-${r.id}`,
|
||||||
|
source: "website",
|
||||||
|
title: r.title,
|
||||||
|
thumb: PLACEHOLDER_IMG,
|
||||||
|
status,
|
||||||
|
statusLabel,
|
||||||
|
date: r.created_at,
|
||||||
|
href: "/admin/content-website",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleHistoryStatus(a: ArticleRow): UnifiedStatus {
|
||||||
|
if (a.isDraft) return "draft";
|
||||||
|
if (a.isPublish) return "approved";
|
||||||
|
const ps = (a.publishStatus || "").toLowerCase();
|
||||||
|
if (ps.includes("reject")) return "rejected";
|
||||||
|
if (a.statusId === 3) return "rejected";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleStatusLabel(s: UnifiedStatus): string {
|
||||||
|
switch (s) {
|
||||||
|
case "draft":
|
||||||
|
return "Draft";
|
||||||
|
case "pending":
|
||||||
|
return "Pending Approval";
|
||||||
|
case "approved":
|
||||||
|
return "Approved";
|
||||||
|
case "rejected":
|
||||||
|
return "Rejected";
|
||||||
|
default:
|
||||||
|
return "Pending Approval";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleToUnified(r: ArticleRow): UnifiedItem {
|
||||||
|
const status = articleHistoryStatus(r);
|
||||||
|
const rawDate = r.createdAt ?? r.created_at ?? "";
|
||||||
|
return {
|
||||||
|
key: `art-${r.id}`,
|
||||||
|
source: "news",
|
||||||
|
title: r.title || "Untitled",
|
||||||
|
thumb: r.thumbnailUrl?.trim() || PLACEHOLDER_IMG,
|
||||||
|
status,
|
||||||
|
statusLabel: articleStatusLabel(status),
|
||||||
|
date: rawDate,
|
||||||
|
href: `/admin/news-article/detail/${r.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: UnifiedStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||||
|
case "approved":
|
||||||
|
return "bg-green-100 text-green-800 border-green-200";
|
||||||
|
case "rejected":
|
||||||
|
return "bg-red-100 text-red-800 border-red-200";
|
||||||
|
case "draft":
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-700 border-slate-200";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 8;
|
||||||
|
|
||||||
export default function MyContent() {
|
export default function MyContent() {
|
||||||
|
const [levelId, setLevelId] = useState<string | undefined>();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<"all" | "news" | "website">(
|
||||||
|
"all",
|
||||||
|
);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<
|
||||||
|
"all" | UnifiedStatus
|
||||||
|
>("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [cmsRows, setCmsRows] = useState<CmsRow[]>([]);
|
||||||
|
const [articleRows, setArticleRows] = useState<ArticleRow[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const isApprover = levelId === "3";
|
||||||
|
const isContributor = levelId === "2";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLevelId(Cookies.get("ulne"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(search), 350);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (levelId === undefined) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const cmsMine = isContributor;
|
||||||
|
const cmsRes = await listCmsContentSubmissions({
|
||||||
|
status: "all",
|
||||||
|
mine: cmsMine,
|
||||||
|
page: 1,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
const cmsData = apiPayload<CmsRow[]>(cmsRes);
|
||||||
|
setCmsRows(Array.isArray(cmsData) ? cmsData : []);
|
||||||
|
|
||||||
|
const artMode = isApprover ? "approver" : "own";
|
||||||
|
const artRes = await getArticlesForMyContent({
|
||||||
|
mode: artMode,
|
||||||
|
page: 1,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
const artData = apiPayload<ArticleRow[]>(artRes);
|
||||||
|
setArticleRows(Array.isArray(artData) ? artData : []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [levelId, isContributor, isApprover]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const mergedAll = useMemo(() => {
|
||||||
|
const cms = cmsRows.map(cmsToUnified);
|
||||||
|
const arts = articleRows.map(articleToUnified);
|
||||||
|
let list = [...cms, ...arts];
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
list = list.filter(
|
||||||
|
(x) =>
|
||||||
|
x.title.toLowerCase().includes(q) ||
|
||||||
|
x.statusLabel.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sourceFilter === "news") list = list.filter((x) => x.source === "news");
|
||||||
|
if (sourceFilter === "website")
|
||||||
|
list = list.filter((x) => x.source === "website");
|
||||||
|
if (statusFilter !== "all")
|
||||||
|
list = list.filter((x) => x.status === statusFilter);
|
||||||
|
list.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||||||
|
return list;
|
||||||
|
}, [cmsRows, articleRows, debouncedSearch, sourceFilter, statusFilter]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const draft = mergedAll.filter((x) => x.status === "draft").length;
|
||||||
|
const pending = mergedAll.filter((x) => x.status === "pending").length;
|
||||||
|
const approved = mergedAll.filter((x) => x.status === "approved").length;
|
||||||
|
const rejected = mergedAll.filter((x) => x.status === "rejected").length;
|
||||||
|
return {
|
||||||
|
total: mergedAll.length,
|
||||||
|
draft,
|
||||||
|
pending,
|
||||||
|
approved,
|
||||||
|
rejected,
|
||||||
|
};
|
||||||
|
}, [mergedAll]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(mergedAll.length / PAGE_SIZE));
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const pageItems = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
return mergedAll.slice(start, start + PAGE_SIZE);
|
||||||
|
}, [mergedAll, currentPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [sourceFilter, statusFilter, debouncedSearch, levelId]);
|
||||||
|
|
||||||
|
if (levelId === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[200px] items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">My Content</h1>
|
<h1 className="text-2xl font-semibold text-slate-900">My Content</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Track all your content submissions and drafts
|
Track all your content submissions and drafts.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-2 max-w-3xl">
|
||||||
|
{isContributor
|
||||||
|
? "Riwayat konten Anda: perubahan Content Website (setelah diajukan) dan artikel. Buka item untuk mengedit atau melanjutkan persetujuan di halaman masing-masing."
|
||||||
|
: isApprover
|
||||||
|
? "Riwayat dari kontributor: Content Website dan News & Articles (tanpa draft). Persetujuan dilakukan di halaman Content Website atau detail artikel."
|
||||||
|
: "Ringkasan konten terkait akun Anda."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
{stats.map((item, index) => (
|
{[
|
||||||
<Card key={index} className="rounded-2xl shadow-sm">
|
{ title: "Total Content", value: stats.total, color: "bg-blue-500" },
|
||||||
|
{ title: "Drafts", value: stats.draft, color: "bg-slate-600" },
|
||||||
|
{ title: "Pending", value: stats.pending, color: "bg-yellow-500" },
|
||||||
|
{ title: "Approved", value: stats.approved, color: "bg-green-600" },
|
||||||
|
{
|
||||||
|
title: "Revision/Rejected",
|
||||||
|
value: stats.rejected,
|
||||||
|
color: "bg-red-600",
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<Card key={item.title} className="rounded-2xl shadow-sm">
|
||||||
<CardContent className="p-5 flex items-center gap-4">
|
<CardContent className="p-5 flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white ${item.color}`}
|
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shrink-0 ${item.color}`}
|
||||||
>
|
/>
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold">{item.value}</p>
|
<p className="text-2xl font-bold text-slate-900">{item.value}</p>
|
||||||
<p className="text-sm text-muted-foreground">{item.title}</p>
|
<p className="text-sm text-muted-foreground">{item.title}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -104,83 +292,136 @@ export default function MyContent() {
|
||||||
<div className="relative w-full md:max-w-md">
|
<div className="relative w-full md:max-w-md">
|
||||||
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search media files..."
|
placeholder="Search by title…"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<Button variant="outline" className="gap-2">
|
<div className="flex items-center gap-2 rounded-lg border bg-white px-3 py-2">
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||||
Filters
|
<select
|
||||||
</Button>
|
className="text-sm bg-transparent border-none outline-none"
|
||||||
</div>
|
value={sourceFilter}
|
||||||
|
onChange={(e) =>
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
setSourceFilter(e.target.value as typeof sourceFilter)
|
||||||
{contents.map((item) => (
|
}
|
||||||
<Card
|
>
|
||||||
key={item.id}
|
<option value="all">All sources</option>
|
||||||
className="rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 bg-white"
|
<option value="news">News & Articles</option>
|
||||||
|
<option value="website">Content Website</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border bg-white px-3 py-2">
|
||||||
|
<select
|
||||||
|
className="text-sm bg-transparent border-none outline-none"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStatusFilter(e.target.value as typeof statusFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="pending">Pending Approval</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => load()}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 px-3">
|
Refresh
|
||||||
<Badge className={getStatusStyle(item.status)}>
|
</Button>
|
||||||
{item.status}
|
</div>
|
||||||
</Badge>
|
</div>
|
||||||
|
|
||||||
<Badge
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-[#966314]" />
|
||||||
|
</div>
|
||||||
|
) : pageItems.length === 0 ? (
|
||||||
|
<p className="text-center text-slate-500 py-16">No content found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{pageItems.map((item) => (
|
||||||
|
<Link key={item.key} href={item.href} className="block group">
|
||||||
|
<Card className="rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 bg-white h-full overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 flex-wrap px-3 pt-3">
|
||||||
|
<Badge
|
||||||
|
className={`${statusBadgeClass(item.status)} border font-medium`}
|
||||||
|
>
|
||||||
|
{item.statusLabel}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-blue-600 border-blue-200 bg-blue-50"
|
||||||
|
>
|
||||||
|
{item.source === "news"
|
||||||
|
? "News & Articles"
|
||||||
|
: "Content Website"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full aspect-[4/3] mt-2 bg-slate-100">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={item.thumb}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardContent className="px-4 pb-4 pt-3">
|
||||||
|
<h3 className="text-sm font-semibold leading-snug line-clamp-2 text-slate-900 group-hover:text-blue-700">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
{item.date ? formatDate(item.date) : "—"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 pt-6">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Showing {(currentPage - 1) * PAGE_SIZE + 1} to{" "}
|
||||||
|
{Math.min(currentPage * PAGE_SIZE, mergedAll.length)} of{" "}
|
||||||
|
{mergedAll.length} items
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-blue-600 border-blue-200 bg-blue-50"
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
>
|
>
|
||||||
{item.category}
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Badge>
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-slate-600 px-2">
|
||||||
|
Page {currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="relative w-full h-50 overflow-hidden">
|
</>
|
||||||
<Image
|
)}
|
||||||
src={item.image}
|
|
||||||
alt={item.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="px-4">
|
|
||||||
{/* TITLE */}
|
|
||||||
<h3 className="text-sm font-semibold leading-snug line-clamp-2">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-500">{item.date}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center items-center gap-2 mt-8">
|
|
||||||
<Button variant="outline" size="sm" className="rounded-lg">
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" className="rounded-lg">
|
|
||||||
2
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" className="rounded-lg">
|
|
||||||
3
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" className="rounded-lg">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,44 @@ export async function getArticlePagination(props: PaginationRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Articles in the approval queue for the current user level (requires auth). */
|
||||||
|
export async function getArticlesPendingApproval(props: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
typeId?: number;
|
||||||
|
}) {
|
||||||
|
const page = props.page ?? 1;
|
||||||
|
const limit = props.limit ?? 20;
|
||||||
|
const typeParam =
|
||||||
|
props.typeId !== undefined && props.typeId !== null
|
||||||
|
? `&typeId=${props.typeId}`
|
||||||
|
: "";
|
||||||
|
return await httpGetInterceptor(
|
||||||
|
`/articles/pending-approval?page=${page}&limit=${limit}${typeParam}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My Content history: own = all articles by current user; approver = non-draft
|
||||||
|
* articles created by contributors (user level 2). Requires auth.
|
||||||
|
*/
|
||||||
|
export async function getArticlesForMyContent(props: {
|
||||||
|
mode: "own" | "approver";
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const page = props.page ?? 1;
|
||||||
|
const limit = props.limit ?? 200;
|
||||||
|
const titleQ =
|
||||||
|
props.title && props.title.trim()
|
||||||
|
? `&title=${encodeURIComponent(props.title.trim())}`
|
||||||
|
: "";
|
||||||
|
return await httpGetInterceptor(
|
||||||
|
`/articles?myContentMode=${props.mode}&page=${page}&limit=${limit}&sort=desc&sortBy=created_at${titleQ}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTopArticles(props: PaginationRequest) {
|
export async function getTopArticles(props: PaginationRequest) {
|
||||||
const { page, limit, search, startDate, endDate, isPublish, category } =
|
const { page, limit, search, startDate, endDate, isPublish, category } =
|
||||||
props;
|
props;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import {
|
||||||
|
httpGetInterceptor,
|
||||||
|
httpPostInterceptor,
|
||||||
|
} from "./http-config/http-interceptor-services";
|
||||||
|
|
||||||
|
export async function submitCmsContentSubmission(body: {
|
||||||
|
domain: string;
|
||||||
|
title: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return httpPostInterceptor("/cms-content-submissions", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCmsContentSubmissions(params?: {
|
||||||
|
/** Omit or `all` for every status (history). */
|
||||||
|
status?: string;
|
||||||
|
mine?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.status != null && params.status !== "") {
|
||||||
|
q.set("status", params.status);
|
||||||
|
}
|
||||||
|
if (params?.mine) q.set("mine", "1");
|
||||||
|
if (params?.page) q.set("page", String(params.page));
|
||||||
|
if (params?.limit) q.set("limit", String(params.limit));
|
||||||
|
const qs = q.toString();
|
||||||
|
return httpGetInterceptor(
|
||||||
|
`/cms-content-submissions${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveCmsContentSubmission(id: string) {
|
||||||
|
return httpPostInterceptor(`/cms-content-submissions/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectCmsContentSubmission(id: string, note?: string) {
|
||||||
|
return httpPostInterceptor(`/cms-content-submissions/${id}/reject`, {
|
||||||
|
note: note ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue