diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 900fa31..11e13c1 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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) { diff --git a/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx b/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx index da1c936..d8e8a16 100644 --- a/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx +++ b/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx @@ -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); diff --git a/src/app/(dashboard)/products/[productId]/edit/page.tsx b/src/app/(dashboard)/products/[productId]/edit/page.tsx index b4bd0e2..e1360dd 100644 --- a/src/app/(dashboard)/products/[productId]/edit/page.tsx +++ b/src/app/(dashboard)/products/[productId]/edit/page.tsx @@ -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(isDraftParam ? "DRAFT" : ""); const [categories, setCategories] = useState([]); @@ -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() { )} {saveError && ( -
-
-
- error -

{saveError}

-
- {errorLog && ( - - )} +
+
+ error +

{saveError}

)} diff --git a/src/app/(dashboard)/products/new/details/page.tsx b/src/app/(dashboard)/products/new/details/page.tsx index 7b1a641..9b2983c 100644 --- a/src/app/(dashboard)/products/new/details/page.tsx +++ b/src/app/(dashboard)/products/new/details/page.tsx @@ -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() { 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" />
+

+ {d.keywordLimit} +

{draft.keywords.length > 0 && (
{draft.keywords.map((keyword) => ( diff --git a/src/app/(dashboard)/products/new/pricing/page.tsx b/src/app/(dashboard)/products/new/pricing/page.tsx index 38d5605..a602566 100644 --- a/src/app/(dashboard)/products/new/pricing/page.tsx +++ b/src/app/(dashboard)/products/new/pricing/page.tsx @@ -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); diff --git a/src/app/(dashboard)/products/new/review/page.tsx b/src/app/(dashboard)/products/new/review/page.tsx index 6df362d..0053064 100644 --- a/src/app/(dashboard)/products/new/review/page.tsx +++ b/src/app/(dashboard)/products/new/review/page.tsx @@ -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>({}); 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() {
{error && ( -
-
-
- error -

{error}

-
- {errorLog && ( - - )} +
+
+ error +

{error}

)} diff --git a/src/app/(dashboard)/products/new/specifications/page.tsx b/src/app/(dashboard)/products/new/specifications/page.tsx index 61ab036..e29ff7b 100644 --- a/src/app/(dashboard)/products/new/specifications/page.tsx +++ b/src/app/(dashboard)/products/new/specifications/page.tsx @@ -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); diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index c369193..ca5be42 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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 ? { diff --git a/src/app/(onboarding)/onboarding/business/page.tsx b/src/app/(onboarding)/onboarding/business/page.tsx index 1f7fd26..5533e8a 100644 --- a/src/app/(onboarding)/onboarding/business/page.tsx +++ b/src/app/(onboarding)/onboarding/business/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useLanguage } from "@/lib/i18n-context"; +import { getBackendErrorMessage } from "@/lib/error-message"; type DocType = "NPWP" | "NIB" | "AKTA" | "OTHER"; @@ -454,12 +455,7 @@ export default function BusinessPage() { : null; if (!res.ok) { - const fallbackMessage = - data?.details || - data?.error || - raw || - b.uploadFail; - throw new Error(fallbackMessage); + throw new Error(getBackendErrorMessage(data || raw, b.uploadFail)); } const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || ""; diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx index 117b87b..9f0c044 100644 --- a/src/app/admin/categories/page.tsx +++ b/src/app/admin/categories/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { getBackendErrorMessage } from "@/lib/error-message"; interface CategoryRow { id: string; @@ -140,7 +141,7 @@ export default function AdminCategoriesPage() { }); const data = await res.json(); 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); setCategories(rows); @@ -174,7 +175,7 @@ export default function AdminCategoriesPage() { }); const data = await res.json(); 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)); } catch (error) { @@ -268,11 +269,7 @@ export default function AdminCategoriesPage() { }); const data = await res.json(); if (!res.ok) { - throw new Error( - data?.responseDesc || - data?.error || - (isEditing ? "Gagal mengubah category" : "Gagal menambah category") - ); + throw new Error(getBackendErrorMessage(data, isEditing ? "Gagal mengubah category" : "Gagal menambah category")); } await loadCategories(isEditing ? editingCategoryId : undefined); @@ -297,7 +294,7 @@ export default function AdminCategoriesPage() { }); const data = await res.json().catch(() => ({})); 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) { @@ -338,11 +335,7 @@ export default function AdminCategoriesPage() { }); const data = await res.json(); if (!res.ok) { - throw new Error( - data?.responseDesc || - data?.error || - (isEditing ? "Gagal mengubah sub-category" : "Gagal menambah sub-category") - ); + throw new Error(getBackendErrorMessage(data, isEditing ? "Gagal mengubah sub-category" : "Gagal menambah sub-category")); } await loadSubcategories(selectedCategoryId); @@ -367,7 +360,7 @@ export default function AdminCategoriesPage() { }); const data = await res.json().catch(() => ({})); 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) { diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 660ed3b..b01eb57 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import { getBackendErrorMessage } from "@/lib/error-message"; interface AdminQueueItem { id: string; @@ -54,7 +55,7 @@ export default function AdminDashboardPage() { }); const result = await res.json(); 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); } catch (err) { diff --git a/src/app/admin/news/[newsId]/edit/page.tsx b/src/app/admin/news/[newsId]/edit/page.tsx index 4e41286..dd27e1d 100644 --- a/src/app/admin/news/[newsId]/edit/page.tsx +++ b/src/app/admin/news/[newsId]/edit/page.tsx @@ -3,6 +3,7 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; +import { getBackendErrorMessage } from "@/lib/error-message"; interface NewsForm { title: string; @@ -73,7 +74,7 @@ function ImageSlot({ label, value, onUpload }: { label: string; value: string; o 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 url = extractImageUrl(data); if (!url) throw new Error("URL gambar tidak ditemukan"); onUpload(url); @@ -245,7 +246,7 @@ export default function AdminNewsEditPage() { body: JSON.stringify(payload), }); 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); setTimeout(() => router.push("/admin/news"), 1500); } catch { diff --git a/src/app/admin/news/new/page.tsx b/src/app/admin/news/new/page.tsx index d91f6d2..d83ad08 100644 --- a/src/app/admin/news/new/page.tsx +++ b/src/app/admin/news/new/page.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useRef, useState } from "react"; import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; +import { getBackendErrorMessage } from "@/lib/error-message"; interface NewsForm { title: string; @@ -75,7 +76,7 @@ function ImageSlot({ label, value, onUpload }: { label: string; value: string; o 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 url = extractImageUrl(data); if (!url) throw new Error("URL gambar tidak ditemukan dari response"); onUpload(url); @@ -182,7 +183,7 @@ export default function AdminNewsNewPage() { body: JSON.stringify(payload), }); 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); setTimeout(() => router.push("/admin/news"), 1500); } catch { diff --git a/src/app/admin/places/PlaceForm.tsx b/src/app/admin/places/PlaceForm.tsx index 9cd1c48..6e0282b 100644 --- a/src/app/admin/places/PlaceForm.tsx +++ b/src/app/admin/places/PlaceForm.tsx @@ -4,6 +4,7 @@ import { useRef, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { COUNTRIES } from "@/lib/countries"; import { resolveBackendImageUrlFromValue } from "@/lib/image-url"; +import { getBackendErrorMessage } from "@/lib/error-message"; function extractImageUrl(data: Record): string | null { const filename = @@ -118,7 +119,7 @@ function ImageSlot({ 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 url = extractImageUrl(data); if (!url) throw new Error("URL gambar tidak ditemukan"); onUpload(url); @@ -322,7 +323,7 @@ export function PlaceForm({ }); const data = await res.json(); if (!res.ok) { - setError(data?.responseDesc || data?.error || "Gagal menyimpan lokasi"); + setError(getBackendErrorMessage(data, "Gagal menyimpan lokasi")); return; } setSuccess(true); diff --git a/src/app/admin/review/[productId]/page.tsx b/src/app/admin/review/[productId]/page.tsx index b4e8c08..5e5f1bd 100644 --- a/src/app/admin/review/[productId]/page.tsx +++ b/src/app/admin/review/[productId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { ProductVariantShowcase } from "@/components/product-variant-showcase"; +import { getBackendErrorMessage } from "@/lib/error-message"; import { resolveBackendImageUrl } from "@/lib/image-url"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; @@ -908,7 +909,7 @@ function AdminReviewDetailPageInner() { }); const data = await res.json(); if (!res.ok) { - setActionError(data?.responseDesc || data?.error || "Gagal memproses review"); + setActionError(getBackendErrorMessage(data, "Gagal memproses review")); return; } setActionSuccess(action === "accept" ? "Update produk berhasil disetujui!" : "Update produk berhasil ditolak!"); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 284b497..0ef9684 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Inter, Manrope } from "next/font/google"; import "./globals.css"; import { I18nProvider } from "@/lib/i18n-context"; +import { AuthSessionGuard } from "@/components/auth-session-guard"; const inter = Inter({ variable: "--font-inter", @@ -60,7 +61,10 @@ export default function RootLayout({ /> - {children} + + + {children} + ); diff --git a/src/components/auth-session-guard.tsx b/src/components/auth-session-guard.tsx new file mode 100644 index 0000000..f8aee61 --- /dev/null +++ b/src/components/auth-session-guard.tsx @@ -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) + .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; +} diff --git a/src/components/upload-field.tsx b/src/components/upload-field.tsx index 3a535b7..ee14fd3 100644 --- a/src/components/upload-field.tsx +++ b/src/components/upload-field.tsx @@ -1,6 +1,7 @@ "use client"; import { useRef, useState } from "react"; +import { getBackendErrorMessage } from "@/lib/error-message"; interface UploadFieldProps { label: string; @@ -72,7 +73,7 @@ export function UploadField({ 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?.id || data?.data?.fileId || data?.fileId || ""; diff --git a/src/lib/error-message.ts b/src/lib/error-message.ts new file mode 100644 index 0000000..0a15f03 --- /dev/null +++ b/src/lib/error-message.ts @@ -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; + 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; +} diff --git a/src/lib/product-draft.tsx b/src/lib/product-draft.tsx index d8ef97e..fa30c8c 100644 --- a/src/lib/product-draft.tsx +++ b/src/lib/product-draft.tsx @@ -116,6 +116,7 @@ interface ProductDraftContextValue { } const STORAGE_KEY = "productWizardDraft"; +const MAX_PRODUCT_KEYWORDS = 3; const defaultDraft: ProductDraftState = { categoryId: "", @@ -187,31 +188,42 @@ const defaultDraft: ProductDraftState = { const ProductDraftContext = createContext(null); -export function ProductDraftProvider({ children }: { children: ReactNode }) { - const [draft, setDraft] = useState(() => { - if (typeof window === "undefined") { - return defaultDraft; - } +function normalizeDraft(stored: Partial): ProductDraftState { + 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(defaultDraft); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) { - return defaultDraft; + setIsLoaded(true); + return; } try { const stored = JSON.parse(raw); // Merge with defaultDraft so any new fields added to the schema // always have a valid default value (handles stale sessionStorage). - return { ...defaultDraft, ...stored } as ProductDraftState; + setDraft(normalizeDraft(stored)); } catch { sessionStorage.removeItem(STORAGE_KEY); - return defaultDraft; } - }); + setIsLoaded(true); + }, []); useEffect(() => { + if (!isLoaded) return; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); - }, [draft]); + }, [draft, isLoaded]); function resetDraft() { setDraft(defaultDraft); diff --git a/src/lib/translations/en.ts b/src/lib/translations/en.ts index 0faa114..29d4480 100644 --- a/src/lib/translations/en.ts +++ b/src/lib/translations/en.ts @@ -474,6 +474,7 @@ export const en = { keywords: "Search Keywords", addKeyword: "Add keyword", addKeywordBtn: "Add", + keywordLimit: "Maximum 3 keywords.", narrative: "Product Narrative", narrativePlaceholder: "Describe the craftsmanship, materials, and value proposition...", features: "Key Product Features", @@ -606,8 +607,6 @@ export const en = { warrantyType: "Warranty Type", warrantyDuration: "Duration", submitting: "Submitting...", - copied: "Copied!", - copyErrorLog: "Copy Error Log", saving: "Saving product...", autoSaved: "Auto-saved", saveDraft: "Save Draft", diff --git a/src/lib/translations/id.ts b/src/lib/translations/id.ts index bb1f6c3..d388e88 100644 --- a/src/lib/translations/id.ts +++ b/src/lib/translations/id.ts @@ -475,6 +475,7 @@ export const id = { keywords: "Kata Kunci Pencarian", addKeyword: "Tambah kata kunci", addKeywordBtn: "Tambah", + keywordLimit: "Maksimal 3 kata kunci.", narrative: "Narasi Produk", narrativePlaceholder: "Deskripsikan keahlian, bahan, dan nilai produk...", features: "Fitur Utama Produk", @@ -607,8 +608,6 @@ export const id = { warrantyType: "Tipe Garansi", warrantyDuration: "Durasi", submitting: "Mengirim...", - copied: "Tersalin!", - copyErrorLog: "Salin Log Error", saving: "Menyimpan produk...", autoSaved: "Tersimpan otomatis", saveDraft: "Simpan Draft", diff --git a/src/lib/use-product-submit.ts b/src/lib/use-product-submit.ts index 014ba2d..6ae14c4 100644 --- a/src/lib/use-product-submit.ts +++ b/src/lib/use-product-submit.ts @@ -2,6 +2,10 @@ import { useState } from "react"; 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() { if (typeof window === "undefined") return ""; @@ -28,7 +32,7 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | " productImages: draft.productImages .filter(Boolean) .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), productModels: draft.models.map((model) => ({ name: model.name, @@ -107,12 +111,10 @@ export function useProductSubmit() { const { draft, resetDraft } = useProductDraft(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); - const [errorLog, setErrorLog] = useState<{ request: unknown; response: unknown } | null>(null); async function submit(state: "DRAFT" | "REVIEW"): Promise { setSubmitting(true); setError(""); - setErrorLog(null); try { const token = getToken(); const payload = buildProductPayload(draft, state); @@ -123,17 +125,16 @@ export function useProductSubmit() { }); const result = await res.json(); if (!res.ok) { - setErrorLog({ request: payload, response: result }); - throw new Error(result?.responseDesc || "Gagal menyimpan produk"); + throw new Error(getBackendErrorMessage(result, FALLBACK_SAVE_ERROR)); } resetDraft(); } catch (err) { - setError(err instanceof Error ? err.message : "Gagal menyimpan produk"); + setError(err instanceof Error ? err.message : FALLBACK_SAVE_ERROR); throw err; } finally { setSubmitting(false); } } - return { submit, submitting, error, errorLog, setError }; + return { submit, submitting, error, setError }; }