Handle expired sessions and clean backend errors
This commit is contained in:
@ -3,6 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
interface AnalyticsPoint {
|
||||
label: string;
|
||||
@ -93,7 +94,7 @@ function DashboardContent() {
|
||||
});
|
||||
const result = await res.json();
|
||||
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);
|
||||
} catch (err) {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { COUNTRIES } from "@/lib/countries";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
export interface WarehouseFormState {
|
||||
name: string;
|
||||
@ -149,7 +150,7 @@ export function WarehouseForm({
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.responseDesc || data?.error || "Gagal menyimpan warehouse");
|
||||
setError(getBackendErrorMessage(data, "Gagal menyimpan warehouse"));
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
|
||||
@ -4,6 +4,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -236,10 +237,10 @@ function hasBackendError(result: unknown): result is { responseCode?: string; re
|
||||
}
|
||||
|
||||
function backendErrorMessage(
|
||||
result: { responseDesc?: string; error?: string } | null | undefined,
|
||||
result: unknown,
|
||||
fallback: string
|
||||
) {
|
||||
return result?.responseDesc || result?.error || fallback;
|
||||
return getBackendErrorMessage(result, fallback);
|
||||
}
|
||||
|
||||
function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> {
|
||||
@ -275,7 +276,7 @@ async function uploadFile(file: File) {
|
||||
const data = await res.json();
|
||||
|
||||
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 || "";
|
||||
@ -1110,8 +1111,6 @@ function EditProductPageInner() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
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 [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||
@ -1352,8 +1351,6 @@ function EditProductPageInner() {
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
setSaveSuccess(false);
|
||||
setErrorLog(null);
|
||||
setErrorLogCopied(false);
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
@ -1365,7 +1362,6 @@ function EditProductPageInner() {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok || hasBackendError(result)) {
|
||||
setErrorLog({ request: payload, response: result });
|
||||
throw new Error(backendErrorMessage(result, "Gagal menyimpan draft"));
|
||||
}
|
||||
|
||||
@ -1383,8 +1379,6 @@ function EditProductPageInner() {
|
||||
setPublishing(true);
|
||||
setSaveError("");
|
||||
setPublishSuccess(false);
|
||||
setErrorLog(null);
|
||||
setErrorLogCopied(false);
|
||||
|
||||
try {
|
||||
const payload = buildPayload("REVIEW");
|
||||
@ -1396,7 +1390,6 @@ function EditProductPageInner() {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok || hasBackendError(result)) {
|
||||
setErrorLog({ request: payload, response: result });
|
||||
throw new Error(backendErrorMessage(result, "Gagal mempublikasikan produk"));
|
||||
}
|
||||
|
||||
@ -1414,8 +1407,6 @@ function EditProductPageInner() {
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
setSaveSuccess(false);
|
||||
setErrorLog(null);
|
||||
setErrorLogCopied(false);
|
||||
|
||||
try {
|
||||
const payload = buildPayload("REVIEW");
|
||||
@ -1427,7 +1418,6 @@ function EditProductPageInner() {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok || hasBackendError(result)) {
|
||||
setErrorLog({ request: payload, response: result });
|
||||
throw new Error(backendErrorMessage(result, "Gagal menyimpan produk"));
|
||||
}
|
||||
|
||||
@ -1934,28 +1924,10 @@ function EditProductPageInner() {
|
||||
</div>
|
||||
)}
|
||||
{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="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
|
||||
<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 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 gap-2">
|
||||
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
|
||||
<p>{saveError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -5,8 +5,10 @@ import { useRouter } from "next/navigation";
|
||||
import { useProductDraft } from "@/lib/product-draft";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
const MAX_IMAGES = 8;
|
||||
const MAX_KEYWORDS = 3;
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
@ -95,7 +97,7 @@ function ImageSlotItem({
|
||||
body: formData,
|
||||
});
|
||||
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 || "";
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(id);
|
||||
@ -169,6 +171,7 @@ export default function ProductDetailsPage() {
|
||||
const { t } = useLanguage();
|
||||
const d = t.dashboard.productNew.details;
|
||||
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)
|
||||
const [slotsCount, setSlotsCount] = useState(() => {
|
||||
@ -220,10 +223,12 @@ export default function ProductDetailsPage() {
|
||||
|
||||
function addKeyword() {
|
||||
const value = keywordInput.trim();
|
||||
if (!value) return;
|
||||
if (!value || keywordLimitReached) return;
|
||||
setDraft((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("");
|
||||
}
|
||||
@ -317,6 +322,7 @@ export default function ProductDetailsPage() {
|
||||
<input
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
disabled={keywordLimitReached}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@ -324,16 +330,20 @@ export default function ProductDetailsPage() {
|
||||
}
|
||||
}}
|
||||
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
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] font-semibold text-on-surface-variant">
|
||||
{d.keywordLimit}
|
||||
</p>
|
||||
{draft.keywords.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{draft.keywords.map((keyword) => (
|
||||
|
||||
@ -6,6 +6,7 @@ import { useProductDraft, type ProductModelDraft, type ProductMeasurementDraft }
|
||||
import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
interface WarehouseOption {
|
||||
id: string;
|
||||
@ -156,7 +157,7 @@ function ModelImageUpload({
|
||||
body: formData,
|
||||
});
|
||||
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 || "";
|
||||
if (!fileId) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(fileId);
|
||||
|
||||
@ -131,10 +131,9 @@ function ReviewImage({
|
||||
export default function ProductReviewPage() {
|
||||
const router = useRouter();
|
||||
const { draft } = useProductDraft();
|
||||
const { submit, submitting, error, errorLog, setError } = useProductSubmit();
|
||||
const { submit, submitting, error, setError } = useProductSubmit();
|
||||
const { t } = useLanguage();
|
||||
const r = t.dashboard.productNew.review;
|
||||
const [errorLogCopied, setErrorLogCopied] = useState(false);
|
||||
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||
const galleryImageIds = draft.productImages.filter(Boolean);
|
||||
const reviewImageIds = [draft.imageId, ...galleryImageIds].filter(
|
||||
@ -164,7 +163,6 @@ export default function ProductReviewPage() {
|
||||
}, []);
|
||||
|
||||
async function handleSaveDraft() {
|
||||
setErrorLogCopied(false);
|
||||
try {
|
||||
await submit("DRAFT");
|
||||
router.push("/products");
|
||||
@ -174,7 +172,6 @@ export default function ProductReviewPage() {
|
||||
}
|
||||
|
||||
async function handleSubmitForReview() {
|
||||
setErrorLogCopied(false);
|
||||
try {
|
||||
await submit("REVIEW");
|
||||
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="w-full space-y-3">
|
||||
{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="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
|
||||
<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 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 gap-2">
|
||||
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useProductDraft } from "@/lib/product-draft";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
@ -34,7 +35,7 @@ function useFileUpload(onSuccess: (fileId: string, fileName: string) => void) {
|
||||
body: formData,
|
||||
});
|
||||
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;
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onSuccess(id, file.name);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getBackendErrorMessage } from "@/lib/error-message";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
@ -51,7 +52,7 @@ function AvatarUpload({
|
||||
body: fd,
|
||||
});
|
||||
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 || "";
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(id, objectUrl);
|
||||
@ -151,7 +152,7 @@ function StorePhotoUpload({
|
||||
body: fd,
|
||||
});
|
||||
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 || "";
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(id, objectUrl);
|
||||
@ -316,7 +317,7 @@ export default function SettingsPage() {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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
|
||||
setProfile((prev) => prev ? {
|
||||
|
||||
Reference in New Issue
Block a user