Refine seller onboarding and product review flows

This commit is contained in:
2026-05-25 10:34:57 +07:00
parent b266047a11
commit 7e6446b4c2
24 changed files with 2238 additions and 764 deletions

View File

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

View File

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

View File

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

View File

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