Fix product actions and warehouse onboarding

This commit is contained in:
2026-05-26 14:03:53 +07:00
parent 8f05576464
commit 4c125a5326
4 changed files with 311 additions and 112 deletions

View File

@ -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({
/>
</div>
{/* Warehouse Type */}
<div>
<label className={labelCls}>Tipe Warehouse</label>
<select
value={form.warehouseType}
onChange={(e) => update({ warehouseType: e.target.value })}
className={inputCls}
>
<option value="INA">INA</option>
<option value="Other">Other</option>
</select>
</div>
{/* Lat / Lng */}
<div>
<label className={labelCls}>Latitude</label>

View File

@ -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);

View File

@ -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<StockPriceTarget | null>(null);
const [warehouseLookupMap, setWarehouseLookupMap] = useState<Record<string, WarehouseLookup>>({});
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
const actionMenuRef = useRef<HTMLDivElement | null>(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<HTMLButtonElement>) {
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(
<div
data-product-action-menu
className="fixed z-[80] w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl"
style={actionMenuPosition}
>
{children}
</div>,
document.body
);
}
useEffect(() => {
async function loadWarehouses() {
try {
@ -1407,22 +1506,17 @@ function ProductsPageInner() {
{p.deletedByAdmin}
</span>
) : (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
<button
type="button"
onClick={() => handleRestore(product.id)}
@ -1432,27 +1526,21 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
{restoringId === product.id ? p.publishing : p.restore}
</button>
</div>
) : null}
</>)}
</div>
)
) : productState === "UNPUBLISHED" ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
<button
type="button"
onClick={() => handlePublish(product.id)}
@ -1470,26 +1558,20 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
</div>
) : null}
</>)}
</div>
) : productState === "DELETED_BY_SELLER" ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
<button
type="button"
onClick={() => handleRestore(product.id)}
@ -1507,26 +1589,20 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
</div>
) : null}
</>)}
</div>
) : (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
{!isInReviewTab ? (
!isDeletedTab ? (
<Link
@ -1579,8 +1655,7 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg">delete</span>
Delete
</button>
</div>
) : null}
</>)}
</div>
)}
</div>

View File

@ -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<Province[]>([]);
const [cities, setCities] = useState<City[]>([]);
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<typeof warehouse>) {
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() {
</label>
<input
value={warehouse.name}
onChange={(e) =>
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() {
</label>
<input
value={warehouse.address}
onChange={(e) =>
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() {
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
{sd.country}
</label>
<input
<select
value={warehouse.country}
onChange={(e) =>
setWarehouse((prev) => ({ ...prev, country: e.target.value }))
updateWarehouse({
country: e.target.value,
province: "",
provinceId: "",
city: "",
cityId: "",
})
}
className={fieldClass}
/>
>
<option value="">Pilih negara...</option>
{COUNTRIES.map((country) => (
<option key={country.code} value={country.name}>
{country.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
{sd.province}
</label>
<input
value={warehouse.province}
onChange={(e) =>
setWarehouse((prev) => ({ ...prev, province: e.target.value }))
}
className={fieldClass}
/>
{isIndonesia ? (
<select
value={warehouse.provinceId}
onChange={(e) => {
const selected = provinces.find((province) => province.id === e.target.value);
updateWarehouse({
provinceId: e.target.value,
province: selected?.name || "",
city: "",
cityId: "",
});
}}
disabled={loadingProvinces}
className={fieldClass}
>
<option value="">
{loadingProvinces ? "Memuat provinsi..." : "Pilih provinsi..."}
</option>
{provinces.map((province) => (
<option key={province.id} value={province.id}>
{province.name}
</option>
))}
</select>
) : (
<input
value={warehouse.province}
onChange={(e) => updateWarehouse({ province: e.target.value })}
placeholder="Nama provinsi / state..."
className={fieldClass}
/>
)}
</div>
<div>
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
{sd.city}
</label>
<input
value={warehouse.city}
onChange={(e) =>
setWarehouse((prev) => ({ ...prev, city: e.target.value }))
}
className={fieldClass}
/>
{isIndonesia ? (
<select
value={warehouse.cityId}
onChange={(e) => {
const selected = cities.find((city) => city.id === e.target.value);
updateWarehouse({
cityId: e.target.value,
city: selected?.name || "",
});
}}
disabled={!warehouse.provinceId || loadingCities}
className={fieldClass}
>
<option value="">
{!warehouse.provinceId
? "Pilih provinsi dulu..."
: loadingCities
? "Memuat kota..."
: "Pilih kota..."}
</option>
{cities.map((city) => (
<option key={city.id} value={city.id}>
{city.name}
</option>
))}
</select>
) : (
<input
value={warehouse.city}
onChange={(e) => updateWarehouse({ city: e.target.value })}
placeholder="Nama kota..."
className={fieldClass}
/>
)}
</div>
<div>
@ -461,12 +600,7 @@ export default function StoreDetailPage() {
</label>
<input
value={warehouse.postalCode}
onChange={(e) =>
setWarehouse((prev) => ({
...prev,
postalCode: e.target.value,
}))
}
onChange={(e) => updateWarehouse({ postalCode: e.target.value })}
className={fieldClass}
/>
</div>
@ -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}
/>