diff --git a/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx b/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx index 0ae4260..da1c936 100644 --- a/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx +++ b/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx @@ -137,7 +137,7 @@ export function WarehouseForm({ postalCode: form.postalCode || null, latitude: form.latitude ? parseFloat(form.latitude) : null, longitude: form.longitude ? parseFloat(form.longitude) : null, - warehouseType: form.warehouseType || null, + warehouseType: "INA", }; try { @@ -296,19 +296,6 @@ export function WarehouseForm({ /> - {/* Warehouse Type */} -
- - -
- {/* Lat / Lng */}
diff --git a/src/app/(dashboard)/products/[productId]/edit/page.tsx b/src/app/(dashboard)/products/[productId]/edit/page.tsx index 677c01b..b4bd0e2 100644 --- a/src/app/(dashboard)/products/[productId]/edit/page.tsx +++ b/src/app/(dashboard)/products/[productId]/edit/page.tsx @@ -229,6 +229,19 @@ function toStr(v: string | number | null | undefined): string { return String(v); } +function hasBackendError(result: unknown): result is { responseCode?: string; responseDesc?: string; error?: string } { + if (!result || typeof result !== "object") return false; + const responseCode = "responseCode" in result ? String(result.responseCode ?? "") : ""; + return Boolean(responseCode && responseCode !== "0000"); +} + +function backendErrorMessage( + result: { responseDesc?: string; error?: string } | null | undefined, + fallback: string +) { + return result?.responseDesc || result?.error || fallback; +} + function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> { if (!Array.isArray(items)) return []; @@ -1351,9 +1364,9 @@ function EditProductPageInner() { body: JSON.stringify(payload), }); const result = await res.json(); - if (!res.ok) { + if (!res.ok || hasBackendError(result)) { setErrorLog({ request: payload, response: result }); - throw new Error(result?.responseDesc || "Gagal menyimpan draft"); + throw new Error(backendErrorMessage(result, "Gagal menyimpan draft")); } setSaveSuccess(true); @@ -1382,9 +1395,9 @@ function EditProductPageInner() { body: JSON.stringify(payload), }); const result = await res.json(); - if (!res.ok) { + if (!res.ok || hasBackendError(result)) { setErrorLog({ request: payload, response: result }); - throw new Error(result?.responseDesc || "Gagal mempublikasikan produk"); + throw new Error(backendErrorMessage(result, "Gagal mempublikasikan produk")); } setPublishSuccess(true); @@ -1413,9 +1426,9 @@ function EditProductPageInner() { body: JSON.stringify(payload), }); const result = await res.json(); - if (!res.ok) { + if (!res.ok || hasBackendError(result)) { setErrorLog({ request: payload, response: result }); - throw new Error(result?.responseDesc || "Gagal menyimpan produk"); + throw new Error(backendErrorMessage(result, "Gagal menyimpan produk")); } setSaveSuccess(true); diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx index 98bdb7b..c496105 100644 --- a/src/app/(dashboard)/products/page.tsx +++ b/src/app/(dashboard)/products/page.tsx @@ -2,7 +2,8 @@ import Image from "next/image"; import Link from "next/link"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { useSearchParams } from "next/navigation"; import { useLanguage } from "@/lib/i18n-context"; import { getProductEffectivePoints } from "@/lib/product-variants"; @@ -52,6 +53,8 @@ interface ProductMeasurementRef { interface ProductModelRef { id?: string | number | null; productModelId?: string | number | null; + image?: string | null; + imageId?: string | null; name?: string | null; sku?: string | null; isMeasurement?: boolean | null; @@ -61,6 +64,9 @@ interface ProductModelRef { } interface ProductDetailRef { + image?: string | null; + imageId?: string | null; + productImages?: Array<{ image?: string | null; imageId?: string | null; sequence?: number | null }> | null; productModels?: ProductModelRef[]; } @@ -139,12 +145,33 @@ function hasMissingListPrice(product: ProductRow) { return (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice) || (minPrice <= 0 && maxPrice <= 0)); } +function hasMissingListImage(product: ProductRow) { + return !product.image; +} + +function getDetailFallbackImage(detail: ProductDetailRef) { + if (detail.image) return detail.image; + + const galleryImage = Array.isArray(detail.productImages) + ? detail.productImages + .slice() + .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) + .find((item) => item.image)?.image + : ""; + + if (galleryImage) return galleryImage; + + return Array.isArray(detail.productModels) + ? detail.productModels.find((model) => model.image)?.image || "" + : ""; +} + async function hydrateRowsWithEffectivePrice( rows: ProductRow[], token: string, query: string ) { - const targets = rows.filter(hasMissingListPrice); + const targets = rows.filter((row) => hasMissingListPrice(row) || hasMissingListImage(row)); if (targets.length === 0) return rows; const details = await Promise.all( @@ -167,25 +194,32 @@ async function hydrateRowsWithEffectivePrice( return rows.map((row) => { const detail = detailMap.get(row.id); - if (!detail || !Array.isArray(detail.productModels)) return row; + if (!detail) return row; - const prices = getProductEffectivePoints(detail.productModels) + const fallbackImage = row.image || getDetailFallbackImage(detail); + const productModels = Array.isArray(detail.productModels) ? detail.productModels : row.productModels; + + const prices = Array.isArray(productModels) + ? getProductEffectivePoints(productModels) .map((point) => point.price) .filter((value): value is number => value !== undefined && value > 0) - .sort((a, b) => a - b); + .sort((a, b) => a - b) + : []; if (prices.length === 0) { return { ...row, - productModels: detail.productModels, + image: fallbackImage || row.image, + productModels, }; } return { ...row, + image: fallbackImage || row.image, minPrice: prices[0], maxPrice: prices[prices.length - 1], - productModels: detail.productModels, + productModels, }; }); } @@ -747,7 +781,11 @@ function ProductsPageInner() { const [stockPriceTarget, setStockPriceTarget] = useState(null); const [warehouseLookupMap, setWarehouseLookupMap] = useState>({}); const [openActionMenuId, setOpenActionMenuId] = useState(null); - const actionMenuRef = useRef(null); + const [actionMenuPosition, setActionMenuPosition] = useState<{ + left: number; + top?: number; + bottom?: number; + } | null>(null); // Reset to page 1 when tab changes useEffect(() => { @@ -760,27 +798,88 @@ function ProductsPageInner() { useEffect(() => { function handlePointerDown(event: MouseEvent) { - if (!actionMenuRef.current) return; - if (!actionMenuRef.current.contains(event.target as Node)) { + const target = event.target as HTMLElement | null; + if (!target?.closest("[data-product-action-menu]")) { setOpenActionMenuId(null); + setActionMenuPosition(null); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpenActionMenuId(null); + setActionMenuPosition(null); } } + function handleViewportChange() { + setOpenActionMenuId(null); + setActionMenuPosition(null); + } + document.addEventListener("mousedown", handlePointerDown); document.addEventListener("keydown", handleEscape); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("keydown", handleEscape); + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); }; }, []); + function closeActionMenu() { + setOpenActionMenuId(null); + setActionMenuPosition(null); + } + + function toggleActionMenu(productId: string, event: React.MouseEvent) { + if (openActionMenuId === productId) { + closeActionMenu(); + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + const menuWidth = 192; + const estimatedMenuHeight = 240; + const viewportPadding = 12; + const shouldOpenUp = rect.bottom + estimatedMenuHeight > window.innerHeight; + + setActionMenuPosition({ + left: Math.max( + viewportPadding, + Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - viewportPadding) + ), + ...(shouldOpenUp + ? { bottom: window.innerHeight - rect.top + 8 } + : { top: rect.bottom + 8 }), + }); + setOpenActionMenuId(productId); + } + + function renderActionMenu(productId: string, children: ReactNode) { + if ( + openActionMenuId !== productId || + !actionMenuPosition || + typeof document === "undefined" + ) { + return null; + } + + return createPortal( +
+ {children} +
, + document.body + ); + } + useEffect(() => { async function loadWarehouses() { try { @@ -1407,22 +1506,17 @@ function ProductsPageInner() { {p.deletedByAdmin} ) : ( -
+
- {openActionMenuId === product.id ? ( -
+ {renderActionMenu(product.id, <> -
- ) : null} + )}
) ) : productState === "UNPUBLISHED" ? ( -
+
- {openActionMenuId === product.id ? ( -
+ {renderActionMenu(product.id, <> -
- ) : null} + )}
) : productState === "DELETED_BY_SELLER" ? ( -
+
- {openActionMenuId === product.id ? ( -
+ {renderActionMenu(product.id, <> -
- ) : null} + )}
) : ( -
+
- {openActionMenuId === product.id ? ( -
+ {renderActionMenu(product.id, <> {!isInReviewTab ? ( !isDeletedTab ? ( delete Delete -
- ) : null} + )}
)}
diff --git a/src/app/(onboarding)/onboarding/store-detail/page.tsx b/src/app/(onboarding)/onboarding/store-detail/page.tsx index a389935..19052b2 100644 --- a/src/app/(onboarding)/onboarding/store-detail/page.tsx +++ b/src/app/(onboarding)/onboarding/store-detail/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { UploadField } from "@/components/upload-field"; +import { COUNTRIES } from "@/lib/countries"; import { useLanguage } from "@/lib/i18n-context"; const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft"; @@ -34,6 +35,14 @@ type WarehouseListRow = { warehouseType?: string | null; }; +type Province = { id: string; name: string }; +type City = { id: string; name: string }; + +function normalizeToken(token: string) { + if (!token) return ""; + return token.startsWith("Bearer ") ? token : `Bearer ${token}`; +} + export default function StoreDetailPage() { const router = useRouter(); const { t } = useLanguage(); @@ -54,11 +63,19 @@ export default function StoreDetailPage() { warehouseType: "INA", country: "Indonesia", province: "", + provinceId: "", city: "", + cityId: "", postalCode: "", latitude: "", longitude: "", }); + const [provinces, setProvinces] = useState([]); + const [cities, setCities] = useState([]); + const [loadingProvinces, setLoadingProvinces] = useState(false); + const [loadingCities, setLoadingCities] = useState(false); + + const isIndonesia = warehouse.country === "Indonesia"; function getToken() { return ( @@ -66,6 +83,10 @@ export default function StoreDetailPage() { ); } + function updateWarehouse(patch: Partial) { + setWarehouse((prev) => ({ ...prev, ...patch })); + } + useEffect(() => { const token = getToken(); const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY); @@ -124,6 +145,63 @@ export default function StoreDetailPage() { } }, [router]); + useEffect(() => { + if (!isIndonesia) { + setProvinces([]); + setCities([]); + return; + } + + setLoadingProvinces(true); + fetch("/api/locations/provinces", { + headers: { "x-auth-token": normalizeToken(getToken()) }, + }) + .then((res) => res.json()) + .then((data) => { + const rows = Array.isArray(data?.rows) ? data.rows : []; + setProvinces(rows); + + setWarehouse((prev) => { + if (prev.provinceId || !prev.province) return prev; + const matched = rows.find( + (item: Province) => + item.name.trim().toLowerCase() === prev.province.trim().toLowerCase() + ); + return matched ? { ...prev, provinceId: matched.id } : prev; + }); + }) + .catch(() => setProvinces([])) + .finally(() => setLoadingProvinces(false)); + }, [isIndonesia]); + + useEffect(() => { + if (!isIndonesia || !warehouse.provinceId) { + setCities([]); + return; + } + + setLoadingCities(true); + fetch(`/api/locations/cities?provinceId=${warehouse.provinceId}`, { + headers: { "x-auth-token": normalizeToken(getToken()) }, + }) + .then((res) => res.json()) + .then((data) => { + const rows = Array.isArray(data?.rows) ? data.rows : []; + setCities(rows); + + setWarehouse((prev) => { + if (prev.cityId || !prev.city) return prev; + const matched = rows.find( + (item: City) => + item.name.trim().toLowerCase() === prev.city.trim().toLowerCase() + ); + return matched ? { ...prev, cityId: matched.id } : prev; + }); + }) + .catch(() => setCities([])) + .finally(() => setLoadingCities(false)); + }, [isIndonesia, warehouse.provinceId]); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); @@ -163,7 +241,7 @@ export default function StoreDetailPage() { postalCode: warehouse.postalCode.trim(), latitude: toNumber(warehouse.latitude), longitude: toNumber(warehouse.longitude), - warehouseType: warehouse.warehouseType, + warehouseType: "INA", }, }; @@ -394,9 +472,7 @@ export default function StoreDetailPage() { - setWarehouse((prev) => ({ ...prev, name: e.target.value })) - } + onChange={(e) => updateWarehouse({ name: e.target.value })} placeholder={sd.warehouseNamePlaceholder} className={headlineFieldClass} /> @@ -408,9 +484,7 @@ export default function StoreDetailPage() { - setWarehouse((prev) => ({ ...prev, address: e.target.value })) - } + onChange={(e) => updateWarehouse({ address: e.target.value })} placeholder={sd.fullAddressPlaceholder} className={fieldClass} /> @@ -420,39 +494,104 @@ export default function StoreDetailPage() { - - setWarehouse((prev) => ({ ...prev, country: e.target.value })) + updateWarehouse({ + country: e.target.value, + province: "", + provinceId: "", + city: "", + cityId: "", + }) } className={fieldClass} - /> + > + + {COUNTRIES.map((country) => ( + + ))} +
- - setWarehouse((prev) => ({ ...prev, province: e.target.value })) - } - className={fieldClass} - /> + {isIndonesia ? ( + + ) : ( + updateWarehouse({ province: e.target.value })} + placeholder="Nama provinsi / state..." + className={fieldClass} + /> + )}
- - setWarehouse((prev) => ({ ...prev, city: e.target.value })) - } - className={fieldClass} - /> + {isIndonesia ? ( + + ) : ( + updateWarehouse({ city: e.target.value })} + placeholder="Nama kota..." + className={fieldClass} + /> + )}
@@ -461,12 +600,7 @@ export default function StoreDetailPage() { - setWarehouse((prev) => ({ - ...prev, - postalCode: e.target.value, - })) - } + onChange={(e) => updateWarehouse({ postalCode: e.target.value })} className={fieldClass} />
@@ -479,12 +613,7 @@ export default function StoreDetailPage() { type="number" step="any" value={warehouse.latitude} - onChange={(e) => - setWarehouse((prev) => ({ - ...prev, - latitude: e.target.value, - })) - } + onChange={(e) => updateWarehouse({ latitude: e.target.value })} placeholder="5.548290" className={fieldClass} /> @@ -498,12 +627,7 @@ export default function StoreDetailPage() { type="number" step="any" value={warehouse.longitude} - onChange={(e) => - setWarehouse((prev) => ({ - ...prev, - longitude: e.target.value, - })) - } + onChange={(e) => updateWarehouse({ longitude: e.target.value })} placeholder="95.323753" className={fieldClass} />