Handle expired sessions and clean backend errors

This commit is contained in:
2026-05-29 16:52:38 +07:00
parent ebef73f40e
commit aa406f5fb1
23 changed files with 264 additions and 125 deletions

View File

@ -3,6 +3,7 @@
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
interface AnalyticsPoint { interface AnalyticsPoint {
label: string; label: string;
@ -93,7 +94,7 @@ function DashboardContent() {
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(result?.responseDesc || result?.error || "Failed to load dashboard"); throw new Error(getBackendErrorMessage(result, "Failed to load dashboard"));
} }
setData(result); setData(result);
} catch (err) { } catch (err) {

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { COUNTRIES } from "@/lib/countries"; import { COUNTRIES } from "@/lib/countries";
import { getBackendErrorMessage } from "@/lib/error-message";
export interface WarehouseFormState { export interface WarehouseFormState {
name: string; name: string;
@ -149,7 +150,7 @@ export function WarehouseForm({
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data?.responseDesc || data?.error || "Gagal menyimpan warehouse"); setError(getBackendErrorMessage(data, "Gagal menyimpan warehouse"));
return; return;
} }
setSuccess(true); setSuccess(true);

View File

@ -4,6 +4,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react"; import { Suspense, useEffect, useRef, useState } from "react";
import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options"; import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -236,10 +237,10 @@ function hasBackendError(result: unknown): result is { responseCode?: string; re
} }
function backendErrorMessage( function backendErrorMessage(
result: { responseDesc?: string; error?: string } | null | undefined, result: unknown,
fallback: string fallback: string
) { ) {
return result?.responseDesc || result?.error || fallback; return getBackendErrorMessage(result, fallback);
} }
function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> { function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> {
@ -275,7 +276,7 @@ async function uploadFile(file: File) {
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Upload gagal"); throw new Error(getBackendErrorMessage(data, "Upload gagal"));
} }
const fileId = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id || ""; const fileId = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id || "";
@ -1110,8 +1111,6 @@ function EditProductPageInner() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(""); const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [errorLog, setErrorLog] = useState<{ request: unknown; response: unknown } | null>(null);
const [errorLogCopied, setErrorLogCopied] = useState(false);
const [productState, setProductState] = useState<string>(isDraftParam ? "DRAFT" : ""); const [productState, setProductState] = useState<string>(isDraftParam ? "DRAFT" : "");
const [categories, setCategories] = useState<CategoryOption[]>([]); const [categories, setCategories] = useState<CategoryOption[]>([]);
@ -1352,8 +1351,6 @@ function EditProductPageInner() {
setSaving(true); setSaving(true);
setSaveError(""); setSaveError("");
setSaveSuccess(false); setSaveSuccess(false);
setErrorLog(null);
setErrorLogCopied(false);
try { try {
const payload = buildPayload(); const payload = buildPayload();
@ -1365,7 +1362,6 @@ function EditProductPageInner() {
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok || hasBackendError(result)) { if (!res.ok || hasBackendError(result)) {
setErrorLog({ request: payload, response: result });
throw new Error(backendErrorMessage(result, "Gagal menyimpan draft")); throw new Error(backendErrorMessage(result, "Gagal menyimpan draft"));
} }
@ -1383,8 +1379,6 @@ function EditProductPageInner() {
setPublishing(true); setPublishing(true);
setSaveError(""); setSaveError("");
setPublishSuccess(false); setPublishSuccess(false);
setErrorLog(null);
setErrorLogCopied(false);
try { try {
const payload = buildPayload("REVIEW"); const payload = buildPayload("REVIEW");
@ -1396,7 +1390,6 @@ function EditProductPageInner() {
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok || hasBackendError(result)) { if (!res.ok || hasBackendError(result)) {
setErrorLog({ request: payload, response: result });
throw new Error(backendErrorMessage(result, "Gagal mempublikasikan produk")); throw new Error(backendErrorMessage(result, "Gagal mempublikasikan produk"));
} }
@ -1414,8 +1407,6 @@ function EditProductPageInner() {
setSaving(true); setSaving(true);
setSaveError(""); setSaveError("");
setSaveSuccess(false); setSaveSuccess(false);
setErrorLog(null);
setErrorLogCopied(false);
try { try {
const payload = buildPayload("REVIEW"); const payload = buildPayload("REVIEW");
@ -1427,7 +1418,6 @@ function EditProductPageInner() {
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok || hasBackendError(result)) { if (!res.ok || hasBackendError(result)) {
setErrorLog({ request: payload, response: result });
throw new Error(backendErrorMessage(result, "Gagal menyimpan produk")); throw new Error(backendErrorMessage(result, "Gagal menyimpan produk"));
} }
@ -1934,28 +1924,10 @@ function EditProductPageInner() {
</div> </div>
)} )}
{saveError && ( {saveError && (
<div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container"> <div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container shadow-lg">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start gap-2">
<div className="flex items-start gap-2"> <span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span> <p>{saveError}</p>
<p>{saveError}</p>
</div>
{errorLog && (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(errorLog, null, 2));
setErrorLogCopied(true);
setTimeout(() => setErrorLogCopied(false), 2000);
}}
className="flex-shrink-0 flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest bg-error/10 hover:bg-error/20 text-error border border-error/30 rounded-lg px-3 py-1.5 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{errorLogCopied ? "check" : "content_copy"}
</span>
{errorLogCopied ? "Copied!" : "Copy Error Log"}
</button>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -5,8 +5,10 @@ import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft"; import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit"; import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
const MAX_IMAGES = 8; const MAX_IMAGES = 8;
const MAX_KEYWORDS = 3;
function getToken() { function getToken() {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
@ -95,7 +97,7 @@ function ImageSlotItem({
body: formData, body: formData,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const id = data?.data?.id || data?.data?.fileId || data?.fileId || ""; const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan"); if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id); onUploaded(id);
@ -169,6 +171,7 @@ export default function ProductDetailsPage() {
const { t } = useLanguage(); const { t } = useLanguage();
const d = t.dashboard.productNew.details; const d = t.dashboard.productNew.details;
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const keywordLimitReached = draft.keywords.filter(Boolean).length >= MAX_KEYWORDS;
// slotsCount tracks how many image slots to show (starts from existing draft state) // slotsCount tracks how many image slots to show (starts from existing draft state)
const [slotsCount, setSlotsCount] = useState(() => { const [slotsCount, setSlotsCount] = useState(() => {
@ -220,10 +223,12 @@ export default function ProductDetailsPage() {
function addKeyword() { function addKeyword() {
const value = keywordInput.trim(); const value = keywordInput.trim();
if (!value) return; if (!value || keywordLimitReached) return;
setDraft((prev) => ({ setDraft((prev) => ({
...prev, ...prev,
keywords: prev.keywords.includes(value) ? prev.keywords : [...prev.keywords, value], keywords: prev.keywords.includes(value)
? prev.keywords
: [...prev.keywords, value].slice(0, MAX_KEYWORDS),
})); }));
setKeywordInput(""); setKeywordInput("");
} }
@ -317,6 +322,7 @@ export default function ProductDetailsPage() {
<input <input
value={keywordInput} value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)} onChange={(e) => setKeywordInput(e.target.value)}
disabled={keywordLimitReached}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@ -324,16 +330,20 @@ export default function ProductDetailsPage() {
} }
}} }}
placeholder={d.addKeyword} placeholder={d.addKeyword}
className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10" className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-60"
/> />
<button <button
type="button" type="button"
onClick={addKeyword} onClick={addKeyword}
className="px-5 py-3 rounded-xl bg-primary text-white font-black text-sm uppercase tracking-[0.12em]" disabled={keywordLimitReached}
className="px-5 py-3 rounded-xl bg-primary text-white font-black text-sm uppercase tracking-[0.12em] disabled:cursor-not-allowed disabled:opacity-50"
> >
{d.addKeywordBtn} {d.addKeywordBtn}
</button> </button>
</div> </div>
<p className="mt-2 text-[11px] font-semibold text-on-surface-variant">
{d.keywordLimit}
</p>
{draft.keywords.length > 0 && ( {draft.keywords.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{draft.keywords.map((keyword) => ( {draft.keywords.map((keyword) => (

View File

@ -6,6 +6,7 @@ import { useProductDraft, type ProductModelDraft, type ProductMeasurementDraft }
import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options"; import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options";
import { useProductSubmit } from "@/lib/use-product-submit"; import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
interface WarehouseOption { interface WarehouseOption {
id: string; id: string;
@ -156,7 +157,7 @@ function ModelImageUpload({
body: formData, body: formData,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || ""; const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!fileId) throw new Error("File id tidak ditemukan"); if (!fileId) throw new Error("File id tidak ditemukan");
onUploaded(fileId); onUploaded(fileId);

View File

@ -131,10 +131,9 @@ function ReviewImage({
export default function ProductReviewPage() { export default function ProductReviewPage() {
const router = useRouter(); const router = useRouter();
const { draft } = useProductDraft(); const { draft } = useProductDraft();
const { submit, submitting, error, errorLog, setError } = useProductSubmit(); const { submit, submitting, error, setError } = useProductSubmit();
const { t } = useLanguage(); const { t } = useLanguage();
const r = t.dashboard.productNew.review; const r = t.dashboard.productNew.review;
const [errorLogCopied, setErrorLogCopied] = useState(false);
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({}); const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
const galleryImageIds = draft.productImages.filter(Boolean); const galleryImageIds = draft.productImages.filter(Boolean);
const reviewImageIds = [draft.imageId, ...galleryImageIds].filter( const reviewImageIds = [draft.imageId, ...galleryImageIds].filter(
@ -164,7 +163,6 @@ export default function ProductReviewPage() {
}, []); }, []);
async function handleSaveDraft() { async function handleSaveDraft() {
setErrorLogCopied(false);
try { try {
await submit("DRAFT"); await submit("DRAFT");
router.push("/products"); router.push("/products");
@ -174,7 +172,6 @@ export default function ProductReviewPage() {
} }
async function handleSubmitForReview() { async function handleSubmitForReview() {
setErrorLogCopied(false);
try { try {
await submit("REVIEW"); await submit("REVIEW");
router.push("/products/new/submitted"); router.push("/products/new/submitted");
@ -487,28 +484,10 @@ export default function ProductReviewPage() {
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40"> <div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
<div className="w-full space-y-3"> <div className="w-full space-y-3">
{error && ( {error && (
<div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container"> <div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container shadow-lg">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start gap-2">
<div className="flex items-start gap-2"> <span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span> <p>{error}</p>
<p>{error}</p>
</div>
{errorLog && (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(errorLog, null, 2));
setErrorLogCopied(true);
setTimeout(() => setErrorLogCopied(false), 2000);
}}
className="flex-shrink-0 flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest bg-error/10 hover:bg-error/20 text-error border border-error/30 rounded-lg px-3 py-1.5 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{errorLogCopied ? "check" : "content_copy"}
</span>
{errorLogCopied ? r.copied : r.copyErrorLog}
</button>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft"; import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit"; import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
function getToken() { function getToken() {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
@ -34,7 +35,7 @@ function useFileUpload(onSuccess: (fileId: string, fileName: string) => void) {
body: formData, body: formData,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const id = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id; const id = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id;
if (!id) throw new Error("File id tidak ditemukan"); if (!id) throw new Error("File id tidak ditemukan");
onSuccess(id, file.name); onSuccess(id, file.name);

View File

@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
@ -51,7 +52,7 @@ function AvatarUpload({
body: fd, body: fd,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const id = data?.data?.id || data?.data?.fileId || data?.fileId || ""; const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan"); if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id, objectUrl); onUploaded(id, objectUrl);
@ -151,7 +152,7 @@ function StorePhotoUpload({
body: fd, body: fd,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const id = data?.data?.id || data?.data?.fileId || data?.fileId || ""; const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan"); if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id, objectUrl); onUploaded(id, objectUrl);
@ -316,7 +317,7 @@ export default function SettingsPage() {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) throw new Error(result?.responseDesc || "Gagal menyimpan profil"); if (!res.ok) throw new Error(getBackendErrorMessage(result, "Gagal menyimpan profil"));
// Update local profile state // Update local profile state
setProfile((prev) => prev ? { setProfile((prev) => prev ? {

View File

@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getBackendErrorMessage } from "@/lib/error-message";
type DocType = "NPWP" | "NIB" | "AKTA" | "OTHER"; type DocType = "NPWP" | "NIB" | "AKTA" | "OTHER";
@ -454,12 +455,7 @@ export default function BusinessPage() {
: null; : null;
if (!res.ok) { if (!res.ok) {
const fallbackMessage = throw new Error(getBackendErrorMessage(data || raw, b.uploadFail));
data?.details ||
data?.error ||
raw ||
b.uploadFail;
throw new Error(fallbackMessage);
} }
const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || ""; const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || "";

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getBackendErrorMessage } from "@/lib/error-message";
interface CategoryRow { interface CategoryRow {
id: string; id: string;
@ -140,7 +141,7 @@ export default function AdminCategoriesPage() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal memuat category"); throw new Error(getBackendErrorMessage(data, "Gagal memuat category"));
} }
const rows = parseCategories(data); const rows = parseCategories(data);
setCategories(rows); setCategories(rows);
@ -174,7 +175,7 @@ export default function AdminCategoriesPage() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal memuat sub-category"); throw new Error(getBackendErrorMessage(data, "Gagal memuat sub-category"));
} }
setSubcategories(parseSubCategories(data)); setSubcategories(parseSubCategories(data));
} catch (error) { } catch (error) {
@ -268,11 +269,7 @@ export default function AdminCategoriesPage() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(getBackendErrorMessage(data, isEditing ? "Gagal mengubah category" : "Gagal menambah category"));
data?.responseDesc ||
data?.error ||
(isEditing ? "Gagal mengubah category" : "Gagal menambah category")
);
} }
await loadCategories(isEditing ? editingCategoryId : undefined); await loadCategories(isEditing ? editingCategoryId : undefined);
@ -297,7 +294,7 @@ export default function AdminCategoriesPage() {
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal menghapus category"); throw new Error(getBackendErrorMessage(data, "Gagal menghapus category"));
} }
if (editingCategoryId === category.id) { if (editingCategoryId === category.id) {
@ -338,11 +335,7 @@ export default function AdminCategoriesPage() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(getBackendErrorMessage(data, isEditing ? "Gagal mengubah sub-category" : "Gagal menambah sub-category"));
data?.responseDesc ||
data?.error ||
(isEditing ? "Gagal mengubah sub-category" : "Gagal menambah sub-category")
);
} }
await loadSubcategories(selectedCategoryId); await loadSubcategories(selectedCategoryId);
@ -367,7 +360,7 @@ export default function AdminCategoriesPage() {
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Gagal menghapus sub-category"); throw new Error(getBackendErrorMessage(data, "Gagal menghapus sub-category"));
} }
if (editingSubCategoryId === subcategory.id) { if (editingSubCategoryId === subcategory.id) {

View File

@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getBackendErrorMessage } from "@/lib/error-message";
interface AdminQueueItem { interface AdminQueueItem {
id: string; id: string;
@ -54,7 +55,7 @@ export default function AdminDashboardPage() {
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(result?.responseDesc || result?.error || "Failed to load admin dashboard"); throw new Error(getBackendErrorMessage(result, "Failed to load admin dashboard"));
} }
setData(result); setData(result);
} catch (err) { } catch (err) {

View File

@ -3,6 +3,7 @@
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
import { getBackendErrorMessage } from "@/lib/error-message";
interface NewsForm { interface NewsForm {
title: string; title: string;
@ -73,7 +74,7 @@ function ImageSlot({ label, value, onUpload }: { label: string; value: string; o
body: formData, body: formData,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const url = extractImageUrl(data); const url = extractImageUrl(data);
if (!url) throw new Error("URL gambar tidak ditemukan"); if (!url) throw new Error("URL gambar tidak ditemukan");
onUpload(url); onUpload(url);
@ -245,7 +246,7 @@ export default function AdminNewsEditPage() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { setError(data?.responseDesc || data?.error || "Gagal memperbarui artikel"); return; } if (!res.ok) { setError(getBackendErrorMessage(data, "Gagal memperbarui artikel")); return; }
setSuccess(true); setSuccess(true);
setTimeout(() => router.push("/admin/news"), 1500); setTimeout(() => router.push("/admin/news"), 1500);
} catch { } catch {

View File

@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
import { getBackendErrorMessage } from "@/lib/error-message";
interface NewsForm { interface NewsForm {
title: string; title: string;
@ -75,7 +76,7 @@ function ImageSlot({ label, value, onUpload }: { label: string; value: string; o
body: formData, body: formData,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const url = extractImageUrl(data); const url = extractImageUrl(data);
if (!url) throw new Error("URL gambar tidak ditemukan dari response"); if (!url) throw new Error("URL gambar tidak ditemukan dari response");
onUpload(url); onUpload(url);
@ -182,7 +183,7 @@ export default function AdminNewsNewPage() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { setError(data?.responseDesc || data?.error || "Gagal menerbitkan news"); return; } if (!res.ok) { setError(getBackendErrorMessage(data, "Gagal menerbitkan news")); return; }
setSuccess(true); setSuccess(true);
setTimeout(() => router.push("/admin/news"), 1500); setTimeout(() => router.push("/admin/news"), 1500);
} catch { } catch {

View File

@ -4,6 +4,7 @@ import { useRef, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { COUNTRIES } from "@/lib/countries"; import { COUNTRIES } from "@/lib/countries";
import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; import { resolveBackendImageUrlFromValue } from "@/lib/image-url";
import { getBackendErrorMessage } from "@/lib/error-message";
function extractImageUrl(data: Record<string, unknown>): string | null { function extractImageUrl(data: Record<string, unknown>): string | null {
const filename = const filename =
@ -118,7 +119,7 @@ function ImageSlot({
body: formData, body: formData,
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal"); if (!res.ok) throw new Error(getBackendErrorMessage(data, "Upload gagal"));
const url = extractImageUrl(data); const url = extractImageUrl(data);
if (!url) throw new Error("URL gambar tidak ditemukan"); if (!url) throw new Error("URL gambar tidak ditemukan");
onUpload(url); onUpload(url);
@ -322,7 +323,7 @@ export function PlaceForm({
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setError(data?.responseDesc || data?.error || "Gagal menyimpan lokasi"); setError(getBackendErrorMessage(data, "Gagal menyimpan lokasi"));
return; return;
} }
setSuccess(true); setSuccess(true);

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { ProductVariantShowcase } from "@/components/product-variant-showcase"; import { ProductVariantShowcase } from "@/components/product-variant-showcase";
import { getBackendErrorMessage } from "@/lib/error-message";
import { resolveBackendImageUrl } from "@/lib/image-url"; import { resolveBackendImageUrl } from "@/lib/image-url";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react"; import { Suspense, useEffect, useRef, useState } from "react";
@ -908,7 +909,7 @@ function AdminReviewDetailPageInner() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setActionError(data?.responseDesc || data?.error || "Gagal memproses review"); setActionError(getBackendErrorMessage(data, "Gagal memproses review"));
return; return;
} }
setActionSuccess(action === "accept" ? "Update produk berhasil disetujui!" : "Update produk berhasil ditolak!"); setActionSuccess(action === "accept" ? "Update produk berhasil disetujui!" : "Update produk berhasil ditolak!");

View File

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter, Manrope } from "next/font/google"; import { Inter, Manrope } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { I18nProvider } from "@/lib/i18n-context"; import { I18nProvider } from "@/lib/i18n-context";
import { AuthSessionGuard } from "@/components/auth-session-guard";
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
@ -60,7 +61,10 @@ export default function RootLayout({
/> />
</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">
<I18nProvider>{children}</I18nProvider> <I18nProvider>
<AuthSessionGuard />
{children}
</I18nProvider>
</body> </body>
</html> </html>
); );

View File

@ -0,0 +1,121 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
const AUTH_ERROR_STATUSES = new Set([401, 403]);
const AUTH_ERROR_PATTERNS = [
"access denied",
"unauthorized",
"session expired",
"token expired",
"invalid token",
];
const PUBLIC_PATH_PREFIXES = [
"/login",
"/register",
"/forgot-password",
"/reset-password",
"/account-not-found",
"/help",
"/privacy",
"/terms",
];
function isPublicPath(pathname: string) {
return PUBLIC_PATH_PREFIXES.some(
(path) => pathname === path || pathname.startsWith(`${path}/`)
);
}
function isLocalApiRequest(input: RequestInfo | URL) {
if (typeof window === "undefined") return false;
const rawUrl =
typeof input === "string" || input instanceof URL ? input.toString() : input.url;
const url = new URL(rawUrl, window.location.origin);
return (
url.origin === window.location.origin &&
url.pathname.startsWith("/api/") &&
!url.pathname.startsWith("/api/auth/")
);
}
function readTextFromPayload(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(readTextFromPayload).join(" ");
if (typeof value !== "object") return String(value);
return Object.values(value as Record<string, unknown>)
.map(readTextFromPayload)
.join(" ");
}
async function hasAuthErrorMessage(response: Response) {
try {
const contentType = response.headers.get("content-type") || "";
const clone = response.clone();
const text = contentType.includes("application/json")
? readTextFromPayload(await clone.json())
: await clone.text();
const normalized = text.toLowerCase();
return AUTH_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
} catch {
return false;
}
}
function clearSessionAndRedirect() {
localStorage.removeItem("token");
localStorage.removeItem("role");
sessionStorage.removeItem("token");
sessionStorage.removeItem("role");
window.location.assign("/login");
}
export function AuthSessionGuard() {
const pathname = usePathname();
const pathnameRef = useRef(pathname);
const redirectingRef = useRef(false);
useEffect(() => {
pathnameRef.current = pathname;
}, [pathname]);
useEffect(() => {
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const response = await originalFetch(input, init);
if (
redirectingRef.current ||
isPublicPath(pathnameRef.current) ||
!isLocalApiRequest(input)
) {
return response;
}
const shouldRedirect =
AUTH_ERROR_STATUSES.has(response.status) ||
(response.status >= 400 && (await hasAuthErrorMessage(response)));
if (shouldRedirect) {
redirectingRef.current = true;
clearSessionAndRedirect();
}
return response;
};
return () => {
window.fetch = originalFetch;
};
}, []);
return null;
}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { getBackendErrorMessage } from "@/lib/error-message";
interface UploadFieldProps { interface UploadFieldProps {
label: string; label: string;
@ -72,7 +73,7 @@ export function UploadField({
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Upload gagal"); throw new Error(getBackendErrorMessage(data, "Upload gagal"));
} }
const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || ""; const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || "";

42
src/lib/error-message.ts Normal file
View File

@ -0,0 +1,42 @@
const JSON_LIKE_START = /^[\s]*[\[{]/;
function normalizeText(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (JSON_LIKE_START.test(trimmed)) {
try {
return getBackendErrorMessage(JSON.parse(trimmed), "");
} catch {
return "";
}
}
return trimmed.length > 220 ? `${trimmed.slice(0, 217)}...` : trimmed;
}
export function getBackendErrorMessage(value: unknown, fallback: string): string {
if (typeof value === "string") {
return normalizeText(value) || fallback;
}
if (!value || typeof value !== "object") {
return fallback;
}
const data = value as Record<string, unknown>;
const candidates = [
data.responseDesc,
data.message,
data.error,
data.details,
data.data,
];
for (const candidate of candidates) {
const message: string = getBackendErrorMessage(candidate, "");
if (message) return message;
}
return fallback;
}

View File

@ -116,6 +116,7 @@ interface ProductDraftContextValue {
} }
const STORAGE_KEY = "productWizardDraft"; const STORAGE_KEY = "productWizardDraft";
const MAX_PRODUCT_KEYWORDS = 3;
const defaultDraft: ProductDraftState = { const defaultDraft: ProductDraftState = {
categoryId: "", categoryId: "",
@ -187,31 +188,42 @@ const defaultDraft: ProductDraftState = {
const ProductDraftContext = createContext<ProductDraftContextValue | null>(null); const ProductDraftContext = createContext<ProductDraftContextValue | null>(null);
export function ProductDraftProvider({ children }: { children: ReactNode }) { function normalizeDraft(stored: Partial<ProductDraftState>): ProductDraftState {
const [draft, setDraft] = useState<ProductDraftState>(() => { return {
if (typeof window === "undefined") { ...defaultDraft,
return defaultDraft; ...stored,
} keywords: Array.isArray(stored.keywords)
? stored.keywords.filter(Boolean).slice(0, MAX_PRODUCT_KEYWORDS)
: defaultDraft.keywords,
};
}
export function ProductDraftProvider({ children }: { children: ReactNode }) {
const [draft, setDraft] = useState<ProductDraftState>(defaultDraft);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const raw = sessionStorage.getItem(STORAGE_KEY); const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) { if (!raw) {
return defaultDraft; setIsLoaded(true);
return;
} }
try { try {
const stored = JSON.parse(raw); const stored = JSON.parse(raw);
// Merge with defaultDraft so any new fields added to the schema // Merge with defaultDraft so any new fields added to the schema
// always have a valid default value (handles stale sessionStorage). // always have a valid default value (handles stale sessionStorage).
return { ...defaultDraft, ...stored } as ProductDraftState; setDraft(normalizeDraft(stored));
} catch { } catch {
sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
return defaultDraft;
} }
}); setIsLoaded(true);
}, []);
useEffect(() => { useEffect(() => {
if (!isLoaded) return;
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
}, [draft]); }, [draft, isLoaded]);
function resetDraft() { function resetDraft() {
setDraft(defaultDraft); setDraft(defaultDraft);

View File

@ -474,6 +474,7 @@ export const en = {
keywords: "Search Keywords", keywords: "Search Keywords",
addKeyword: "Add keyword", addKeyword: "Add keyword",
addKeywordBtn: "Add", addKeywordBtn: "Add",
keywordLimit: "Maximum 3 keywords.",
narrative: "Product Narrative", narrative: "Product Narrative",
narrativePlaceholder: "Describe the craftsmanship, materials, and value proposition...", narrativePlaceholder: "Describe the craftsmanship, materials, and value proposition...",
features: "Key Product Features", features: "Key Product Features",
@ -606,8 +607,6 @@ export const en = {
warrantyType: "Warranty Type", warrantyType: "Warranty Type",
warrantyDuration: "Duration", warrantyDuration: "Duration",
submitting: "Submitting...", submitting: "Submitting...",
copied: "Copied!",
copyErrorLog: "Copy Error Log",
saving: "Saving product...", saving: "Saving product...",
autoSaved: "Auto-saved", autoSaved: "Auto-saved",
saveDraft: "Save Draft", saveDraft: "Save Draft",

View File

@ -475,6 +475,7 @@ export const id = {
keywords: "Kata Kunci Pencarian", keywords: "Kata Kunci Pencarian",
addKeyword: "Tambah kata kunci", addKeyword: "Tambah kata kunci",
addKeywordBtn: "Tambah", addKeywordBtn: "Tambah",
keywordLimit: "Maksimal 3 kata kunci.",
narrative: "Narasi Produk", narrative: "Narasi Produk",
narrativePlaceholder: "Deskripsikan keahlian, bahan, dan nilai produk...", narrativePlaceholder: "Deskripsikan keahlian, bahan, dan nilai produk...",
features: "Fitur Utama Produk", features: "Fitur Utama Produk",
@ -607,8 +608,6 @@ export const id = {
warrantyType: "Tipe Garansi", warrantyType: "Tipe Garansi",
warrantyDuration: "Durasi", warrantyDuration: "Durasi",
submitting: "Mengirim...", submitting: "Mengirim...",
copied: "Tersalin!",
copyErrorLog: "Salin Log Error",
saving: "Menyimpan produk...", saving: "Menyimpan produk...",
autoSaved: "Tersimpan otomatis", autoSaved: "Tersimpan otomatis",
saveDraft: "Simpan Draft", saveDraft: "Simpan Draft",

View File

@ -2,6 +2,10 @@
import { useState } from "react"; import { useState } from "react";
import { useProductDraft, type ProductDraftState } from "./product-draft"; import { useProductDraft, type ProductDraftState } from "./product-draft";
import { getBackendErrorMessage } from "./error-message";
const MAX_PRODUCT_KEYWORDS = 3;
const FALLBACK_SAVE_ERROR = "Gagal menyimpan produk";
function getToken() { function getToken() {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
@ -28,7 +32,7 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "
productImages: draft.productImages productImages: draft.productImages
.filter(Boolean) .filter(Boolean)
.map((imageId, index) => ({ imageId, sequence: index + 1 })), .map((imageId, index) => ({ imageId, sequence: index + 1 })),
productKeyWords: draft.keywords.filter(Boolean), productKeyWords: draft.keywords.filter(Boolean).slice(0, MAX_PRODUCT_KEYWORDS),
productFeatures: draft.features.filter(Boolean), productFeatures: draft.features.filter(Boolean),
productModels: draft.models.map((model) => ({ productModels: draft.models.map((model) => ({
name: model.name, name: model.name,
@ -107,12 +111,10 @@ export function useProductSubmit() {
const { draft, resetDraft } = useProductDraft(); const { draft, resetDraft } = useProductDraft();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [errorLog, setErrorLog] = useState<{ request: unknown; response: unknown } | null>(null);
async function submit(state: "DRAFT" | "REVIEW"): Promise<void> { async function submit(state: "DRAFT" | "REVIEW"): Promise<void> {
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
setErrorLog(null);
try { try {
const token = getToken(); const token = getToken();
const payload = buildProductPayload(draft, state); const payload = buildProductPayload(draft, state);
@ -123,17 +125,16 @@ export function useProductSubmit() {
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) { if (!res.ok) {
setErrorLog({ request: payload, response: result }); throw new Error(getBackendErrorMessage(result, FALLBACK_SAVE_ERROR));
throw new Error(result?.responseDesc || "Gagal menyimpan produk");
} }
resetDraft(); resetDraft();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Gagal menyimpan produk"); setError(err instanceof Error ? err.message : FALLBACK_SAVE_ERROR);
throw err; throw err;
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} }
return { submit, submitting, error, errorLog, setError }; return { submit, submitting, error, setError };
} }