Refine seller onboarding and product review flows
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
interface AnalyticsPoint {
|
||||
@ -31,6 +32,25 @@ interface SellerDashboardPayload {
|
||||
recentOrders: DashboardOrder[];
|
||||
}
|
||||
|
||||
interface ProductSearchRow {
|
||||
id: string;
|
||||
name: string;
|
||||
market?: string | null;
|
||||
state?: string | null;
|
||||
status?: string | null;
|
||||
totalStock?: number | null;
|
||||
}
|
||||
|
||||
interface WarehouseSearchRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
country: string | null;
|
||||
warehouseType: string | null;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
@ -52,10 +72,14 @@ function statusColor(status: string) {
|
||||
return "text-primary bg-primary/10";
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
function DashboardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLanguage();
|
||||
const d = t.dashboard.overview;
|
||||
const [data, setData] = useState<SellerDashboardPayload | null>(null);
|
||||
const [productResults, setProductResults] = useState<ProductSearchRow[]>([]);
|
||||
const [warehouseResults, setWarehouseResults] = useState<WarehouseSearchRow[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@ -84,8 +108,101 @@ export default function DashboardPage() {
|
||||
|
||||
const analytics = data?.analytics || [];
|
||||
const maxAnalytics = Math.max(...analytics.map((item) => item.value), 1);
|
||||
const totalRevenue = (data?.recentOrders || []).reduce((sum, item) => sum + item.amount, 0);
|
||||
const totalOrders = data?.recentOrders.length || 0;
|
||||
const rawDashboardQuery = (searchParams.get("q") || "").trim();
|
||||
const dashboardQuery = rawDashboardQuery.toLowerCase();
|
||||
const filteredOrders = (data?.recentOrders || []).filter((order) => {
|
||||
if (!dashboardQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
order.product,
|
||||
order.sku,
|
||||
order.customer,
|
||||
order.location,
|
||||
order.date,
|
||||
order.status,
|
||||
String(order.amount),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(dashboardQuery);
|
||||
});
|
||||
const totalRevenue = filteredOrders.reduce((sum, item) => sum + item.amount, 0);
|
||||
const totalOrders = filteredOrders.length;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSearchResults() {
|
||||
if (!dashboardQuery) {
|
||||
setProductResults([]);
|
||||
setWarehouseResults([]);
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchLoading(true);
|
||||
|
||||
try {
|
||||
const [productsRes, warehousesRes] = await Promise.all([
|
||||
fetch("/api/products?page=1&size=100", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
}),
|
||||
fetch("/api/products/warehouses?page=1&size=100", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
}),
|
||||
]);
|
||||
|
||||
const [productsResult, warehousesResult] = await Promise.all([
|
||||
productsRes.json().catch(() => ({})),
|
||||
warehousesRes.json().catch(() => ({})),
|
||||
]);
|
||||
|
||||
const products: ProductSearchRow[] = Array.isArray(productsResult?.rows)
|
||||
? productsResult.rows
|
||||
: Array.isArray(productsResult?.data?.rows)
|
||||
? productsResult.data.rows
|
||||
: [];
|
||||
|
||||
const warehouses: WarehouseSearchRow[] = Array.isArray(warehousesResult?.rows)
|
||||
? warehousesResult.rows
|
||||
: Array.isArray(warehousesResult?.data)
|
||||
? warehousesResult.data
|
||||
: [];
|
||||
|
||||
setProductResults(
|
||||
products.filter((item) =>
|
||||
[item.name, item.market, item.state, item.status, String(item.totalStock ?? "")]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(dashboardQuery)
|
||||
)
|
||||
);
|
||||
|
||||
setWarehouseResults(
|
||||
warehouses.filter((item) =>
|
||||
[
|
||||
item.name || "",
|
||||
item.address || "",
|
||||
item.city || "",
|
||||
item.province || "",
|
||||
item.country || "",
|
||||
item.warehouseType || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(dashboardQuery)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
setProductResults([]);
|
||||
setWarehouseResults([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSearchResults();
|
||||
}, [dashboardQuery]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
@ -96,6 +213,101 @@ export default function DashboardPage() {
|
||||
<p className="text-on-surface-variant font-medium">{d.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{dashboardQuery ? (
|
||||
<div className="mb-10 rounded-2xl border border-surface-container bg-surface-container-lowest p-8 magazine-shadow">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.22em] text-primary">
|
||||
Dashboard Search
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-black tracking-tight text-on-surface">
|
||||
Hasil pencarian untuk "{rawDashboardQuery}"
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-on-surface-variant">
|
||||
{searchLoading
|
||||
? "Mencari data..."
|
||||
: `${filteredOrders.length} order, ${productResults.length} produk, ${warehouseResults.length} warehouse`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-2xl bg-surface p-5">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
||||
Orders
|
||||
</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filteredOrders.slice(0, 4).map((order) => (
|
||||
<div key={order.id} className="rounded-xl border border-surface-container p-4">
|
||||
<p className="font-bold text-on-surface">{order.product}</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">{order.customer} · {order.location}</p>
|
||||
</div>
|
||||
))}
|
||||
{!searchLoading && filteredOrders.length === 0 ? (
|
||||
<p className="text-sm font-medium text-on-surface-variant">Tidak ada order yang cocok.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
||||
Products
|
||||
</h3>
|
||||
<a href="/products" className="text-xs font-bold text-primary hover:underline">
|
||||
Lihat semua
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{productResults.slice(0, 4).map((product) => (
|
||||
<a
|
||||
key={product.id}
|
||||
href="/products"
|
||||
className="block rounded-xl border border-surface-container p-4 transition-colors hover:bg-surface-container-low"
|
||||
>
|
||||
<p className="font-bold text-on-surface">{product.name}</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">
|
||||
{product.market || product.state || product.status || "Produk"}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
{!searchLoading && productResults.length === 0 ? (
|
||||
<p className="text-sm font-medium text-on-surface-variant">Tidak ada produk yang cocok.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
||||
Warehouses
|
||||
</h3>
|
||||
<a href="/dashboard/warehouse" className="text-xs font-bold text-primary hover:underline">
|
||||
Lihat semua
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{warehouseResults.slice(0, 4).map((warehouse) => (
|
||||
<a
|
||||
key={warehouse.id}
|
||||
href="/dashboard/warehouse"
|
||||
className="block rounded-xl border border-surface-container p-4 transition-colors hover:bg-surface-container-low"
|
||||
>
|
||||
<p className="font-bold text-on-surface">{warehouse.name || "Tanpa nama"}</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">
|
||||
{[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || warehouse.address || "Warehouse"}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
{!searchLoading && warehouseResults.length === 0 ? (
|
||||
<p className="text-sm font-medium text-on-surface-variant">Tidak ada warehouse yang cocok.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10">
|
||||
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-primary"></div>
|
||||
@ -220,7 +432,14 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="bg-surface-container-lowest rounded-xl magazine-shadow overflow-hidden">
|
||||
<div className="p-8 flex items-center justify-between border-b border-surface-container">
|
||||
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
|
||||
<div>
|
||||
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
|
||||
{dashboardQuery ? (
|
||||
<p className="mt-2 text-sm font-medium text-on-surface-variant">
|
||||
Menampilkan hasil pencarian untuk <span className="font-bold text-on-surface">"{searchParams.get("q")}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button className="text-primary font-bold text-sm hover:underline flex items-center gap-1">
|
||||
{d.viewAll}
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
@ -239,7 +458,7 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-container">
|
||||
{(data?.recentOrders || []).map((order) => (
|
||||
{filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors">
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
@ -270,10 +489,10 @@ export default function DashboardPage() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && !error && (data?.recentOrders || []).length === 0 ? (
|
||||
{!loading && !error && filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-8 py-10 text-center text-sm font-semibold text-on-surface-variant">
|
||||
No recent orders available.
|
||||
{dashboardQuery ? "Tidak ada order yang cocok dengan pencarian Anda." : "No recent orders available."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
@ -290,3 +509,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@ -43,6 +44,13 @@ export default function DashboardLayout({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const currentQuery = new URLSearchParams(window.location.search).get("q") || "";
|
||||
setSearchQuery(currentQuery);
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
@ -52,29 +60,58 @@ export default function DashboardLayout({
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextQuery = searchQuery.trim();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (nextQuery) {
|
||||
searchParams.set("q", nextQuery);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
router.push(queryString ? `/dashboard?${queryString}` : "/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
{/* Top Nav */}
|
||||
<header className="fixed top-0 w-full z-50 flex justify-between items-center px-6 h-16 bg-white/85 backdrop-blur-md shadow-sm border-b border-surface-container">
|
||||
<div className="flex items-center gap-8">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={120} height={32} priority />
|
||||
<div className="hidden md:flex items-center bg-surface-container-low px-4 py-2 rounded-xl gap-2">
|
||||
<form
|
||||
onSubmit={handleSearchSubmit}
|
||||
className="hidden md:flex items-center bg-surface-container-low px-4 py-2 rounded-xl gap-2"
|
||||
>
|
||||
<AppIcon name="search" className="h-5 w-5 text-on-surface-variant" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
className="bg-transparent border-none outline-none text-sm w-64 text-on-surface placeholder:text-on-surface-variant"
|
||||
placeholder={t.dashboard.layout.searchPlaceholder}
|
||||
type="text"
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<button className="text-on-surface-variant hover:text-primary transition-colors p-2 rounded-full relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
className="text-outline/70 p-2 rounded-full relative cursor-not-allowed opacity-70"
|
||||
>
|
||||
<AppIcon name="notifications" className="h-5 w-5" />
|
||||
<span className="absolute top-2.5 right-2.5 w-2 h-2 bg-primary rounded-full"></span>
|
||||
</button>
|
||||
<button className="text-on-surface-variant hover:text-primary transition-colors p-2 rounded-full">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
className="text-outline/70 p-2 rounded-full cursor-not-allowed opacity-70"
|
||||
>
|
||||
<AppIcon name="chat" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
@ -37,6 +38,20 @@ interface ProductMeasurement {
|
||||
}
|
||||
|
||||
interface ProductModel {
|
||||
name?: string;
|
||||
sku?: string;
|
||||
imageId?: string;
|
||||
price?: string | number;
|
||||
currency?: string;
|
||||
weight?: string | number;
|
||||
weightType?: string;
|
||||
length?: string | number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
dimensionType?: string;
|
||||
isConfigurePromotionPrice?: boolean;
|
||||
promotionPrice?: string | number;
|
||||
promotionCurrency?: string;
|
||||
warehouses?: ProductWarehouse[];
|
||||
productMeasurements?: ProductMeasurement[];
|
||||
}
|
||||
@ -440,100 +455,14 @@ function ProductDetailPageInner() {
|
||||
|
||||
{/* ── Section 03: Pricing & Model ───────────────────────────────────── */}
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white text-xs font-black shadow-md shadow-primary/20">03</div>
|
||||
<h2 className="text-xl font-black font-headline tracking-tight text-on-surface">{d.section03} ({models.length})</h2>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{models.map((m: any, i: number) => {
|
||||
const weightUnit = m.weightType || "G";
|
||||
const dimUnit = m.dimensionType || "CM";
|
||||
const pkgWeightUnit = m.packagingWeightType || "G";
|
||||
const pkgDimUnit = m.packagingDimensionType || "CM";
|
||||
const measurements = Array.isArray(m.productMeasurements) ? m.productMeasurements : [];
|
||||
return (
|
||||
<div key={i} className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-surface-container">
|
||||
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center text-white text-xs font-black">{i + 1}</div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-on-surface">{m.name || `Model ${i + 1}`}</h3>
|
||||
{m.sku && <span className="text-[10px] text-outline font-bold ml-2">SKU: {m.sku}</span>}
|
||||
{measurements.length > 0 && (
|
||||
<span className="ml-auto text-[10px] font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">{measurements.length} measurement(s)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<Row label={d.price} value={m.price ? `${m.currency || "IDR"} ${Number(m.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label={`${d.price.includes("H") ? "Berat" : "Weight"} (${weightUnit})`} value={m.weight} />
|
||||
<Row label={`Dim (${dimUnit})`} value={[m.length, m.width, m.height].filter(Boolean).join(" × ") || undefined} />
|
||||
{m.isConfigurePromotionPrice && <Row label="Promo" value={m.promotionPrice ? `${m.promotionCurrency || m.currency || "IDR"} ${Number(m.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
{m.isConfigurePromotionPrice && m.promotionStartDate && (
|
||||
<Row label="Promo Period" value={`${m.promotionStartDate} → ${m.promotionEndDate}`} />
|
||||
)}
|
||||
<Row label={`Pkg Weight (${pkgWeightUnit})`} value={m.packagingWeight} />
|
||||
<Row label={`Pkg Dim (${pkgDimUnit})`} value={[m.packagingLength, m.packagingWidth, m.packagingHeight].filter(Boolean).join(" × ") || undefined} />
|
||||
</div>
|
||||
{/* Warehouses */}
|
||||
{Array.isArray(m.warehouses) && m.warehouses.length > 0 && (
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.warehouseStock}</p>
|
||||
<div className="space-y-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-1 border-b border-surface-container last:border-0">
|
||||
<span>{warehouseMap[w.id] || w.name || `${w.id?.slice(0, 8)}...`}</span>
|
||||
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Measurements */}
|
||||
{measurements.length > 0 && (
|
||||
<div className="px-6 pb-6 border-t border-surface-container mt-2">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mt-4 mb-3">Measurements / Variants</p>
|
||||
<div className="space-y-3">
|
||||
{measurements.map((ms: ProductMeasurement, mi: number) => {
|
||||
const msWeightUnit = ms.weightType || "G";
|
||||
const msDimUnit = ms.dimensionType || "CM";
|
||||
return (
|
||||
<div key={mi} className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-outline bg-surface-container px-2.5 py-1 rounded-full">
|
||||
{String(mi + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{ms.measurementType && <span className="text-xs font-bold text-on-surface">{ms.measurementType}</span>}
|
||||
{ms.measurementValue && <span className="text-xs text-on-surface-variant">— {ms.measurementValue}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<Row label="Harga" value={ms.price ? `${ms.currency || "IDR"} ${Number(ms.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label={`Berat (${msWeightUnit})`} value={ms.weight} />
|
||||
<Row label={`Dimensi (${msDimUnit})`} value={[ms.length, ms.width, ms.height].filter(Boolean).join(" × ") || undefined} />
|
||||
{ms.isConfigurePromotionPrice && <Row label="Harga Promo" value={ms.promotionPrice ? `${ms.promotionCurrency || ms.currency || "IDR"} ${Number(ms.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
</div>
|
||||
{Array.isArray(ms.warehouses) &&
|
||||
ms.warehouses.filter((w: ProductWarehouse) => w.id).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-outline-variant/10">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-outline mb-1.5">Stock</p>
|
||||
{ms.warehouses
|
||||
.filter((w: ProductWarehouse) => w.id)
|
||||
.map((w: ProductWarehouse, wi: number) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
|
||||
<span>{warehouseMap[w.id || ""] || `${w.id?.slice(0, 8)}...`}</span>
|
||||
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="03" title={`${d.section03} (${models.length})`} />
|
||||
<ProductVariantShowcase
|
||||
product={product}
|
||||
warehouseLabelResolver={(warehouse) =>
|
||||
warehouse.id ? warehouseMap[String(warehouse.id)] || String(warehouse.id) : warehouse.name || "-"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getProductEffectivePoints } from "@/lib/product-variants";
|
||||
|
||||
type TabLabel =
|
||||
| "All Product"
|
||||
@ -28,6 +29,7 @@ interface ProductRow {
|
||||
status?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
totalStock: number;
|
||||
productModels?: ProductModelRef[];
|
||||
}
|
||||
|
||||
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
|
||||
@ -103,17 +105,89 @@ function getToken() {
|
||||
}
|
||||
|
||||
function formatPrice(product: ProductRow) {
|
||||
if (!product.minPrice && !product.maxPrice) {
|
||||
return "-";
|
||||
const minPrice = Number(product.minPrice);
|
||||
const maxPrice = Number(product.maxPrice);
|
||||
const hasApiRange = Number.isFinite(minPrice) && Number.isFinite(maxPrice) && (minPrice > 0 || maxPrice > 0);
|
||||
|
||||
if (hasApiRange) {
|
||||
const formatter = new Intl.NumberFormat("id-ID");
|
||||
if (minPrice === maxPrice) {
|
||||
return `Rp ${formatter.format(minPrice)}`;
|
||||
}
|
||||
return `Rp ${formatter.format(minPrice)} - ${formatter.format(maxPrice)}`;
|
||||
}
|
||||
|
||||
const models = Array.isArray(product.productModels) ? product.productModels : [];
|
||||
const prices = getProductEffectivePoints(models)
|
||||
.map((point) => point.price)
|
||||
.filter((value): value is number => value !== undefined && value > 0)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (prices.length === 0) return "-";
|
||||
|
||||
const formatter = new Intl.NumberFormat("id-ID");
|
||||
|
||||
if (product.minPrice === product.maxPrice) {
|
||||
return `Rp ${formatter.format(product.minPrice)}`;
|
||||
if (prices[0] === prices[prices.length - 1]) {
|
||||
return `Rp ${formatter.format(prices[0])}`;
|
||||
}
|
||||
|
||||
return `Rp ${formatter.format(product.minPrice)} - ${formatter.format(product.maxPrice)}`;
|
||||
return `Rp ${formatter.format(prices[0])} - ${formatter.format(prices[prices.length - 1])}`;
|
||||
}
|
||||
|
||||
function hasMissingListPrice(product: ProductRow) {
|
||||
const minPrice = Number(product.minPrice);
|
||||
const maxPrice = Number(product.maxPrice);
|
||||
return (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice) || (minPrice <= 0 && maxPrice <= 0));
|
||||
}
|
||||
|
||||
async function hydrateRowsWithEffectivePrice(
|
||||
rows: ProductRow[],
|
||||
token: string,
|
||||
query: string
|
||||
) {
|
||||
const targets = rows.filter(hasMissingListPrice);
|
||||
if (targets.length === 0) return rows;
|
||||
|
||||
const details = await Promise.all(
|
||||
targets.map(async (row) => {
|
||||
try {
|
||||
const res = await fetch(`/api/products/${row.id}${query ? `?${query}` : ""}`, {
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
const result = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return [row.id, null] as const;
|
||||
const data = result?.data || result;
|
||||
return [row.id, data] as const;
|
||||
} catch {
|
||||
return [row.id, null] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const detailMap = new Map(details);
|
||||
|
||||
return rows.map((row) => {
|
||||
const detail = detailMap.get(row.id);
|
||||
if (!detail || !Array.isArray(detail.productModels)) return row;
|
||||
|
||||
const prices = getProductEffectivePoints(detail.productModels)
|
||||
.map((point) => point.price)
|
||||
.filter((value): value is number => value !== undefined && value > 0)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (prices.length === 0) {
|
||||
return {
|
||||
...row,
|
||||
productModels: detail.productModels,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
minPrice: prices[0],
|
||||
maxPrice: prices[prices.length - 1],
|
||||
productModels: detail.productModels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function marketClasses(market: string) {
|
||||
@ -745,9 +819,10 @@ function ProductsPageInner() {
|
||||
if (tab) params.set("tab", tab);
|
||||
params.set("page", String(page));
|
||||
params.set("size", "20");
|
||||
const token = getToken();
|
||||
|
||||
const res = await fetch(`/api/products?${params.toString()}`,
|
||||
{ headers: { "x-auth-token": getToken() } }
|
||||
{ headers: { "x-auth-token": token } }
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
@ -755,7 +830,13 @@ function ProductsPageInner() {
|
||||
throw new Error(result?.responseDesc || "Failed to load products");
|
||||
}
|
||||
|
||||
setRows(result?.rows || result?.data?.rows || []);
|
||||
const nextRows = result?.rows || result?.data?.rows || [];
|
||||
const requestParams = new URLSearchParams();
|
||||
if (tab === "draft") requestParams.set("draft", "1");
|
||||
if (tab === "in-review") requestParams.set("review", "1");
|
||||
const hydratedRows = await hydrateRowsWithEffectivePrice(nextRows, token, requestParams.toString());
|
||||
|
||||
setRows(hydratedRows);
|
||||
setTotalItem(result?.totalItem || result?.data?.totalItem || 0);
|
||||
setTotalPage(result?.totalPage || result?.data?.totalPage || 0);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user