Adjust product state actions and icon font loading

This commit is contained in:
2026-05-10 20:25:43 +07:00
parent d8a0b9b05e
commit 9fb4cd2aa7
6 changed files with 171 additions and 43 deletions

View File

@ -30,6 +30,8 @@ interface ProductRow {
totalStock: number; totalStock: number;
} }
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
function getToken() { function getToken() {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return ""; return "";
@ -184,6 +186,8 @@ function ProductsPageInner() {
const [unpublishTarget, setUnpublishTarget] = useState<ProductRow | null>(null); const [unpublishTarget, setUnpublishTarget] = useState<ProductRow | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [unpublishing, setUnpublishing] = useState(false); const [unpublishing, setUnpublishing] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
// Reset to page 1 when tab changes // Reset to page 1 when tab changes
useEffect(() => { useEffect(() => {
@ -263,6 +267,7 @@ function ProductsPageInner() {
} }
async function handleRestore(productId: string) { async function handleRestore(productId: string) {
setRestoringId(productId);
try { try {
const res = await fetch(`/api/products/${productId}?action=restore`, { const res = await fetch(`/api/products/${productId}?action=restore`, {
method: "PUT", method: "PUT",
@ -277,9 +282,37 @@ function ProductsPageInner() {
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : p.restoreError); alert(err instanceof Error ? err.message : p.restoreError);
} finally {
setRestoringId(null);
} }
} }
async function handlePublish(productId: string) {
setPublishingId(productId);
try {
const res = await fetch(`/api/products/${productId}?action=publish`, {
method: "PUT",
headers: { "x-auth-token": getToken() },
});
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result?.responseDesc || p.publishError);
}
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : p.publishError);
}
finally {
setPublishingId(null);
}
}
function getProductState(product: ProductRow): ProductState {
return (product.state || product.status || product.reviewStatus || "").toUpperCase();
}
const internationalCount = rows.filter( const internationalCount = rows.filter(
(row) => row.market === "International" (row) => row.market === "International"
).length; ).length;
@ -411,6 +444,12 @@ function ProductsPageInner() {
</tr> </tr>
) : ( ) : (
rows.map((product) => { rows.map((product) => {
const productState = getProductState(product);
const isInactiveInAllTab =
activeTab === "All Product" &&
(productState === "UNPUBLISHED" ||
productState === "DELETED_BY_SELLER" ||
productState === "DELETED_BY_ADMIN");
const stockTone = const stockTone =
product.totalStock === 0 product.totalStock === 0
? "red" ? "red"
@ -441,7 +480,7 @@ function ProductsPageInner() {
fill fill
sizes="44px" sizes="44px"
className={`object-cover ${ className={`object-cover ${
stockTone === "red" ? "grayscale" : "" stockTone === "red" || isInactiveInAllTab ? "grayscale" : ""
}`} }`}
/> />
) : ( ) : (
@ -453,7 +492,7 @@ function ProductsPageInner() {
<div> <div>
<p <p
className={`text-xs font-bold transition-colors group-hover:text-primary ${ className={`text-xs font-bold transition-colors group-hover:text-primary ${
stockTone === "red" stockTone === "red" || isInactiveInAllTab
? "text-outline line-through" ? "text-outline line-through"
: "text-on-surface" : "text-on-surface"
}`} }`}
@ -469,7 +508,7 @@ function ProductsPageInner() {
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<span <span
className={`text-xs font-bold ${ className={`text-xs font-bold ${
stockTone === "red" stockTone === "red" || isInactiveInAllTab
? "text-outline" ? "text-outline"
: "text-on-surface" : "text-on-surface"
}`} }`}
@ -503,13 +542,48 @@ function ProductsPageInner() {
<span <span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-tight ${marketClasses( className={`inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-tight ${marketClasses(
product.market product.market
)}`} )} ${isInactiveInAllTab ? "opacity-60 line-through" : ""}`}
> >
{product.market} {product.market}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
{isDeletedTab ? (
productState === "DELETED_BY_ADMIN" ? (
<span className="rounded-full bg-error-container px-3 py-1 text-[10px] font-bold text-on-error-container">
{p.deletedByAdmin}
</span>
) : (
<button
type="button"
onClick={() => handleRestore(product.id)}
disabled={restoringId === product.id}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{restoringId === product.id ? p.publishing : p.restore}
</button>
)
) : productState === "UNPUBLISHED" ? (
<button
type="button"
onClick={() => handlePublish(product.id)}
disabled={publishingId === product.id}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{publishingId === product.id ? p.publishing : p.publish}
</button>
) : productState === "DELETED_BY_SELLER" ? (
<button
type="button"
onClick={() => handleRestore(product.id)}
disabled={restoringId === product.id}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{restoringId === product.id ? p.publishing : p.restore}
</button>
) : (
<>
{!isInReviewTab ? ( {!isInReviewTab ? (
!isDeletedTab ? ( !isDeletedTab ? (
<Link <Link
@ -526,16 +600,6 @@ function ProductsPageInner() {
> >
{p.detail} {p.detail}
</Link> </Link>
{isDeletedTab ? (
<button
type="button"
onClick={() => handleRestore(product.id)}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90"
>
{p.restore}
</button>
) : (
<>
{canUnpublish ? ( {canUnpublish ? (
<button <button
type="button" type="button"

View File

@ -12,6 +12,9 @@ interface ProductRow {
minPrice: number | null; minPrice: number | null;
maxPrice: number | null; maxPrice: number | null;
totalStock: number | null; totalStock: number | null;
state?: string | null;
status?: string | null;
reviewStatus?: string | null;
} }
function getToken() { function getToken() {
@ -32,6 +35,10 @@ function marketBadge(market: string | null) {
return "bg-tertiary-fixed text-on-tertiary-fixed"; return "bg-tertiary-fixed text-on-tertiary-fixed";
} }
function getProductState(product: ProductRow) {
return (product.state || product.status || product.reviewStatus || "").toUpperCase();
}
function ConfirmModal({ function ConfirmModal({
title, title,
message, message,
@ -124,6 +131,7 @@ function AdminProductsPageInner() {
const [restoreTarget, setRestoreTarget] = useState<ProductRow | null>(null); const [restoreTarget, setRestoreTarget] = useState<ProductRow | null>(null);
const [processingDelete, setProcessingDelete] = useState(false); const [processingDelete, setProcessingDelete] = useState(false);
const [processingRestore, setProcessingRestore] = useState(false); const [processingRestore, setProcessingRestore] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const pageSize = 20; const pageSize = 20;
useEffect(() => { useEffect(() => {
@ -204,6 +212,25 @@ function AdminProductsPageInner() {
} }
} }
async function handlePublish(productId: string) {
setPublishingId(productId);
try {
const res = await fetch(`/api/products/${productId}?action=publish`, {
method: "PUT",
headers: { "x-auth-token": getToken() },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.responseDesc || "Gagal publish produk");
}
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Gagal publish produk");
} finally {
setPublishingId(null);
}
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
@ -271,23 +298,36 @@ function AdminProductsPageInner() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
{rows.map((product) => ( {rows.map((product) => {
const productState = getProductState(product);
const isInactiveInAllTab =
!isDeletedTab &&
(productState === "UNPUBLISHED" ||
productState === "DELETED_BY_SELLER" ||
productState === "DELETED_BY_ADMIN");
return (
<tr key={product.id} className="group hover:bg-surface-container-low transition-colors"> <tr key={product.id} className="group hover:bg-surface-container-low transition-colors">
<td className="px-6 py-5"> <td className="px-6 py-5">
<div> <div>
<p className="font-extrabold text-on-surface transition-colors group-hover:text-primary"> <p
className={`font-extrabold transition-colors group-hover:text-primary ${
isInactiveInAllTab ? "text-slate-400 line-through" : "text-on-surface"
}`}
>
{product.name} {product.name}
</p> </p>
<p className="mt-1 text-[10px] font-medium text-slate-400">ID: {product.id.slice(0, 8)}</p> <p className="mt-1 text-[10px] font-medium text-slate-400">ID: {product.id.slice(0, 8)}</p>
</div> </div>
</td> </td>
<td className="px-6 py-5"> <td className="px-6 py-5">
<span className={`inline-flex rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-wider ${marketBadge(product.market)}`}> <span className={`inline-flex rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-wider ${marketBadge(product.market)} ${isInactiveInAllTab ? "line-through opacity-60" : ""}`}>
{product.market || "—"} {product.market || "—"}
</span> </span>
</td> </td>
<td className="px-6 py-5"> <td className="px-6 py-5">
<span className="font-bold text-on-surface">{formatPrice(product.minPrice, product.maxPrice)}</span> <span className={isInactiveInAllTab ? "font-bold text-slate-400 line-through" : "font-bold text-on-surface"}>
{formatPrice(product.minPrice, product.maxPrice)}
</span>
</td> </td>
<td className="px-6 py-5 text-center"> <td className="px-6 py-5 text-center">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-sm font-bold text-on-surface"> <span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-sm font-bold text-on-surface">
@ -296,15 +336,6 @@ function AdminProductsPageInner() {
</td> </td>
<td className="px-6 py-5"> <td className="px-6 py-5">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{!isDeletedTab ? (
<Link
href={`/admin/products/${product.id}`}
className="rounded-xl px-4 py-2 text-[10px] font-black uppercase tracking-widest text-primary transition-colors hover:bg-primary/5"
>
Detail
</Link>
) : null}
{isDeletedTab ? ( {isDeletedTab ? (
<button <button
type="button" type="button"
@ -313,7 +344,31 @@ function AdminProductsPageInner() {
> >
Restore Restore
</button> </button>
) : productState === "UNPUBLISHED" ? (
<button
type="button"
onClick={() => handlePublish(product.id)}
disabled={publishingId === product.id}
className="rounded-xl bg-tertiary px-4 py-2 text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{publishingId === product.id ? "Processing..." : "Publish"}
</button>
) : productState === "DELETED_BY_SELLER" ? (
<button
type="button"
onClick={() => setRestoreTarget(product)}
className="rounded-xl bg-tertiary px-4 py-2 text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-tertiary/90"
>
Restore
</button>
) : ( ) : (
<>
<Link
href={`/admin/products/${product.id}`}
className="rounded-xl px-4 py-2 text-[10px] font-black uppercase tracking-widest text-primary transition-colors hover:bg-primary/5"
>
Detail
</Link>
<button <button
type="button" type="button"
onClick={() => setDeleteTarget(product)} onClick={() => setDeleteTarget(product)}
@ -321,11 +376,12 @@ function AdminProductsPageInner() {
> >
Delete Delete
</button> </button>
</>
)} )}
</div> </div>
</td> </td>
</tr> </tr>
))} )})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -41,11 +41,13 @@ export async function PUT(
const isDraft = req.nextUrl.searchParams.get("draft") === "1"; const isDraft = req.nextUrl.searchParams.get("draft") === "1";
const action = req.nextUrl.searchParams.get("action"); const action = req.nextUrl.searchParams.get("action");
if (action === "unpublish" || action === "restore") { if (action === "unpublish" || action === "restore" || action === "publish") {
const endpoint = const endpoint =
action === "unpublish" action === "unpublish"
? `${API_URL}/api/v1.0/seller/product/${productId}/unpublish` ? `${API_URL}/api/v1.0/seller/product/${productId}/unpublish`
: `${API_URL}/api/v1.0/seller/product/${productId}/restore`; : action === "restore"
? `${API_URL}/api/v1.0/seller/product/${productId}/restore`
: `${API_URL}/api/v1.0/seller/product/${productId}/publish`;
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: "PUT", method: "PUT",

View File

@ -56,7 +56,7 @@ export default function RootLayout({
{/* eslint-disable-next-line @next/next/no-page-custom-font */} {/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=optional" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block"
/> />
</head> </head>
<body className="min-h-screen bg-background text-on-surface font-body antialiased"> <body className="min-h-screen bg-background text-on-surface font-body antialiased">

View File

@ -358,8 +358,10 @@ export const en = {
empty: "No products found.", empty: "No products found.",
edit: "Edit", edit: "Edit",
detail: "Detail", detail: "Detail",
publish: "Publish",
restore: "Restore", restore: "Restore",
unpublish: "Unpublish", unpublish: "Unpublish",
deletedByAdmin: "Deleted by admin",
table: { table: {
product: "Product", product: "Product",
price: "Price", price: "Price",
@ -389,6 +391,7 @@ export const en = {
errorGeneric: "Failed to unpublish product", errorGeneric: "Failed to unpublish product",
}, },
restoreError: "Failed to restore product", restoreError: "Failed to restore product",
publishError: "Failed to publish product",
tabs: { tabs: {
allProduct: "All Product", allProduct: "All Product",
draft: "Draft", draft: "Draft",

View File

@ -359,8 +359,10 @@ export const id = {
empty: "Tidak ada produk ditemukan.", empty: "Tidak ada produk ditemukan.",
edit: "Edit", edit: "Edit",
detail: "Detail", detail: "Detail",
publish: "Publish",
restore: "Restore", restore: "Restore",
unpublish: "Unpublish", unpublish: "Unpublish",
deletedByAdmin: "Dihapus oleh admin",
table: { table: {
product: "Produk", product: "Produk",
price: "Harga", price: "Harga",
@ -390,6 +392,7 @@ export const id = {
errorGeneric: "Gagal unpublish produk", errorGeneric: "Gagal unpublish produk",
}, },
restoreError: "Gagal restore produk", restoreError: "Gagal restore produk",
publishError: "Gagal publish produk",
tabs: { tabs: {
allProduct: "Semua Produk", allProduct: "Semua Produk",
draft: "Draft", draft: "Draft",