Handle expired sessions and clean backend errors
This commit is contained in:
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 ? {
|
||||||
|
|||||||
@ -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 || "";
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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!");
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
121
src/components/auth-session-guard.tsx
Normal file
121
src/components/auth-session-guard.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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
42
src/lib/error-message.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user