-
-
03
-
{d.section03} ({models.length})
-
- {/* 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 (
-
-
-
{i + 1}
-
{m.name || `Model ${i + 1}`}
- {m.sku &&
SKU: {m.sku}}
- {measurements.length > 0 && (
-
{measurements.length} measurement(s)
- )}
-
-
-
-
-
- {m.isConfigurePromotionPrice &&
}
- {m.isConfigurePromotionPrice && m.promotionStartDate && (
-
- )}
-
-
-
- {/* Warehouses */}
- {Array.isArray(m.warehouses) && m.warehouses.length > 0 && (
-
-
{d.warehouseStock}
-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => (
-
- {warehouseMap[w.id] || w.name || `${w.id?.slice(0, 8)}...`}
- {w.stock ?? 0} unit
-
- ))}
-
-
- )}
- {/* Measurements */}
- {measurements.length > 0 && (
-
-
Measurements / Variants
-
- {measurements.map((ms: ProductMeasurement, mi: number) => {
- const msWeightUnit = ms.weightType || "G";
- const msDimUnit = ms.dimensionType || "CM";
- return (
-
-
-
- {String(mi + 1).padStart(2, "0")}
-
- {ms.measurementType && {ms.measurementType}}
- {ms.measurementValue && — {ms.measurementValue}}
-
-
-
-
-
- {ms.isConfigurePromotionPrice &&
}
-
- {Array.isArray(ms.warehouses) &&
- ms.warehouses.filter((w: ProductWarehouse) => w.id).length > 0 && (
-
-
Stock
- {ms.warehouses
- .filter((w: ProductWarehouse) => w.id)
- .map((w: ProductWarehouse, wi: number) => (
-
- {warehouseMap[w.id || ""] || `${w.id?.slice(0, 8)}...`}
- {w.stock ?? 0} unit
-
- ))}
-
- )}
-
- );
- })}
-
-
- )}
-
- );
- })}
+
+
+
+ warehouse.id ? warehouseMap[String(warehouse.id)] || String(warehouse.id) : warehouse.name || "-"
+ }
+ />
)}
diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx
index a24e0b2..98bdb7b 100644
--- a/src/app/(dashboard)/products/page.tsx
+++ b/src/app/(dashboard)/products/page.tsx
@@ -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) {
diff --git a/src/app/(onboarding)/layout.tsx b/src/app/(onboarding)/layout.tsx
index 93869da..08163de 100644
--- a/src/app/(onboarding)/layout.tsx
+++ b/src/app/(onboarding)/layout.tsx
@@ -2,15 +2,13 @@
import Image from "next/image";
import Link from "next/link";
-import { useEffect } from "react";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname } from "next/navigation";
import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context";
const steps = [
{ href: "/onboarding/business", icon: "storefront", labelKey: "business" as const, step: 1 },
{ href: "/onboarding/store-detail", icon: "store", labelKey: "storeDetail" as const, step: 2 },
- { href: "/onboarding/plan", icon: "payments", labelKey: "plan" as const, step: 3 },
];
export default function OnboardingLayout({
@@ -19,7 +17,6 @@ export default function OnboardingLayout({
children: React.ReactNode;
}) {
const pathname = usePathname();
- const router = useRouter();
const { t } = useLanguage();
const lo = t.onboarding.layout;
@@ -27,16 +24,6 @@ export default function OnboardingLayout({
const currentStep = steps.find((s) => pathname.startsWith(s.href));
const stepNumber = currentStep?.step ?? (isSuccessPage ? steps.length : 1);
- useEffect(() => {
- if (pathname.startsWith("/onboarding")) {
- router.replace("/dashboard");
- }
- }, [pathname, router]);
-
- if (pathname.startsWith("/onboarding")) {
- return null;
- }
-
if (isSuccessPage) {
return (
diff --git a/src/app/(onboarding)/onboarding/plan/page.tsx b/src/app/(onboarding)/onboarding/plan/page.tsx
index e2e2a6a..2fb5f9b 100644
--- a/src/app/(onboarding)/onboarding/plan/page.tsx
+++ b/src/app/(onboarding)/onboarding/plan/page.tsx
@@ -1,371 +1,14 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { useLanguage } from "@/lib/i18n-context";
-
-type PlanId = "starter" | "professional" | "organization" | "enterprise";
-
-interface PlanOption {
- id: PlanId;
- borderClass: string;
- actionClass: string;
- badge?: boolean;
- featureKeys: string[];
- price: string;
- unit: string;
- seats: string;
- ctaKey: "currentStep" | "selectPlan" | "contactUs";
-}
-
-const PLANS: PlanOption[] = [
- {
- id: "starter",
- price: "Free",
- unit: "/mo",
- seats: "1 Seat",
- borderClass: "border-slate-200",
- actionClass: "bg-surface-container-high text-on-surface hover:bg-surface-dim",
- ctaKey: "currentStep",
- featureKeys: ["basicAnalytics", "upTo10Trades", "apiAccess"],
- },
- {
- id: "professional",
- price: "$29",
- unit: "/mo",
- seats: "5 Seats",
- borderClass: "border-primary ring-2 ring-primary/20 shadow-2xl shadow-primary/10",
- actionClass: "bg-gradient-to-br from-primary to-primary-container text-on-primary shadow-md shadow-primary/20 hover:opacity-95",
- badge: true,
- ctaKey: "selectPlan",
- featureKeys: ["advancedReports", "unlimitedTrades", "exportToExcel"],
- },
- {
- id: "organization",
- price: "$99",
- unit: "/mo",
- seats: "20 Seats",
- borderClass: "border-tertiary/40",
- actionClass: "bg-secondary text-on-secondary hover:opacity-90",
- ctaKey: "selectPlan",
- featureKeys: ["fullApiAccess", "customDashboards", "teamManagement"],
- },
- {
- id: "enterprise",
- price: "Talk",
- unit: "/sales",
- seats: "Unlimited",
- borderClass: "border-inverse-surface/30",
- actionClass: "border-2 border-on-surface bg-transparent text-on-surface hover:bg-on-surface hover:text-white",
- ctaKey: "contactUs",
- featureKeys: ["slaGuarantee", "dedicatedManager", "customIntegrations"],
- },
-];
-
-const PLAN_NAMES: Record
= {
- starter: "Starter",
- professional: "Professional",
- organization: "Organization",
- enterprise: "Enterprise",
-};
-
-const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft";
-const ONBOARDING_STORE_STORAGE_KEY = "onboardingStoreDetailDraft";
-
-// Which features are included per plan (index 0,1,2 correspond to featureKeys)
-const PLAN_INCLUDED: Record = {
- starter: [true, true, false],
- professional: [true, true, true],
- organization: [true, true, true],
- enterprise: [true, true, true],
-};
-
-function featureIconClass(planId: PlanId) {
- if (planId === "organization") return "text-tertiary";
- if (planId === "enterprise") return "text-on-surface";
- return "text-primary";
-}
export default function PlanPage() {
const router = useRouter();
- const { t } = useLanguage();
- const p = t.onboarding.plan;
- const common = t.common;
-
- const [selectedPlan, setSelectedPlan] = useState(() => {
- if (typeof window === "undefined") return "professional";
- const storedPlan = sessionStorage.getItem("selectedPlan") as PlanId | null;
- return storedPlan && PLANS.some((plan) => plan.id === storedPlan)
- ? storedPlan
- : "professional";
- });
- const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState("");
-
- function getToken() {
- return (
- sessionStorage.getItem("token") || localStorage.getItem("token") || ""
- );
- }
useEffect(() => {
- const token = getToken();
- const businessDraft = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
- const storeDraft = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
-
- if (!token) {
- router.replace("/login");
- return;
- }
-
- if (!businessDraft) {
- router.replace("/onboarding/business");
- return;
- }
-
- if (!storeDraft) {
- router.replace("/onboarding/store-detail");
- }
+ router.replace("/onboarding/store-detail");
}, [router]);
- function persistPlan(planId: PlanId) {
- setSelectedPlan(planId);
- sessionStorage.setItem("selectedPlan", planId);
- }
-
- async function handleContinue() {
- setError("");
- setSubmitting(true);
-
- try {
- sessionStorage.setItem("selectedPlan", selectedPlan);
-
- const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
- if (!businessDraftRaw) {
- throw new Error("Business onboarding data not found");
- }
-
- const storeDraftRaw = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
- if (!storeDraftRaw) {
- throw new Error("Store detail onboarding data not found");
- }
-
- const businessDraft = JSON.parse(businessDraftRaw) as {
- payload?: Record;
- };
-
- const storeDraft = JSON.parse(storeDraftRaw) as {
- payload?: {
- store: Record;
- warehouse: Record;
- };
- };
-
- if (!businessDraft.payload) {
- throw new Error("Business onboarding payload not found");
- }
-
- if (!storeDraft.payload?.store || !storeDraft.payload?.warehouse) {
- throw new Error("Store detail onboarding payload not found");
- }
-
- const sellerRes = await fetch("/api/seller", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "x-auth-token": getToken(),
- },
- body: JSON.stringify(businessDraft.payload),
- });
-
- const sellerData = await sellerRes.json().catch(() => ({}));
- if (!sellerRes.ok) {
- throw new Error(
- sellerData?.error || sellerData?.responseDesc || common.connectionError
- );
- }
-
- const storeRes = await fetch("/api/seller/store", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- "x-auth-token": getToken(),
- },
- body: JSON.stringify(storeDraft.payload.store),
- });
-
- const storeData = await storeRes.json().catch(() => ({}));
- if (!storeRes.ok) {
- throw new Error(
- storeData?.error || storeData?.responseDesc || common.connectionError
- );
- }
-
- const warehouseRes = await fetch("/api/warehouses", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "x-auth-token": getToken(),
- },
- body: JSON.stringify(storeDraft.payload.warehouse),
- });
-
- const warehouseData = await warehouseRes.json().catch(() => ({}));
- if (!warehouseRes.ok) {
- throw new Error(
- warehouseData?.error ||
- warehouseData?.responseDesc ||
- common.connectionError
- );
- }
-
- sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY);
- sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY);
- router.push("/onboarding/success");
- } catch (err) {
- setError(
- err instanceof Error ? err.message : common.connectionError
- );
- } finally {
- setSubmitting(false);
- }
- }
-
- return (
- <>
-
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
- {p.step}
-
-
-
- {p.title}
-
-
- {p.subtitle}
-
-
-
-
- {PLANS.map((plan) => {
- const isSelected = selectedPlan === plan.id;
- const included = PLAN_INCLUDED[plan.id];
-
- return (
-
- {plan.badge ? (
-
- {p.mostPopular}
-
- ) : null}
-
-
- {PLAN_NAMES[plan.id]}
-
-
-
- {plan.price}
- {plan.unit}
-
-
-
-
- group
- {plan.seats}
-
-
-
- {plan.featureKeys.map((key, idx) => {
- const featureLabel = p.features[key as keyof typeof p.features];
- const isIncluded = included[idx];
- return (
-
-
- {isIncluded ? "check" : "close"}
-
- {featureLabel}
-
- );
- })}
-
-
-
-
-
- );
- })}
-
-
-
-
-
-
- >
- );
+ return null;
}
diff --git a/src/app/(onboarding)/onboarding/store-detail/page.tsx b/src/app/(onboarding)/onboarding/store-detail/page.tsx
index 39ec4a8..a389935 100644
--- a/src/app/(onboarding)/onboarding/store-detail/page.tsx
+++ b/src/app/(onboarding)/onboarding/store-detail/page.tsx
@@ -17,8 +17,6 @@ const headlineFieldClass =
const textareaClass =
"w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-3.5 text-sm leading-relaxed text-on-surface shadow-sm transition-all focus:border-primary focus:bg-surface-container-lowest focus:outline-none";
-type WarehouseType = "INA" | "OTHER";
-
function toNumber(value: string) {
const normalized = value.trim();
if (!normalized) return 0;
@@ -26,10 +24,21 @@ function toNumber(value: string) {
return Number.isFinite(parsed) ? parsed : 0;
}
+type WarehouseListRow = {
+ id?: string;
+ name?: string | null;
+ address?: string | null;
+ city?: string | null;
+ province?: string | null;
+ postalCode?: string | null;
+ warehouseType?: string | null;
+};
+
export default function StoreDetailPage() {
const router = useRouter();
const { t } = useLanguage();
const sd = t.onboarding.storeDetail;
+ const common = t.common;
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -42,7 +51,7 @@ export default function StoreDetailPage() {
const [warehouse, setWarehouse] = useState({
name: "",
address: "",
- warehouseType: "INA" as WarehouseType,
+ warehouseType: "INA",
country: "Indonesia",
province: "",
city: "",
@@ -115,7 +124,7 @@ export default function StoreDetailPage() {
}
}, [router]);
- function handleSubmit(e: React.FormEvent) {
+ async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setSubmitting(true);
@@ -158,6 +167,22 @@ export default function StoreDetailPage() {
},
};
+ const businessDraftRaw = sessionStorage.getItem(
+ ONBOARDING_BUSINESS_STORAGE_KEY
+ );
+
+ if (!businessDraftRaw) {
+ throw new Error(sd.genericError);
+ }
+
+ const businessDraft = JSON.parse(businessDraftRaw) as {
+ payload?: Record;
+ };
+
+ if (!businessDraft.payload) {
+ throw new Error(sd.genericError);
+ }
+
sessionStorage.setItem(
ONBOARDING_STORE_STORAGE_KEY,
JSON.stringify({
@@ -167,9 +192,101 @@ export default function StoreDetailPage() {
})
);
- router.push("/onboarding/plan");
+ const sellerRes = await fetch("/api/seller", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-auth-token": getToken(),
+ },
+ body: JSON.stringify(businessDraft.payload),
+ });
+
+ const sellerData = await sellerRes.json().catch(() => ({}));
+ if (!sellerRes.ok) {
+ throw new Error(
+ sellerData?.error || sellerData?.responseDesc || common.connectionError
+ );
+ }
+
+ const storeRes = await fetch("/api/seller/store", {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "x-auth-token": getToken(),
+ },
+ body: JSON.stringify(payload.store),
+ });
+
+ const storeData = await storeRes.json().catch(() => ({}));
+ if (!storeRes.ok) {
+ throw new Error(
+ storeData?.error || storeData?.responseDesc || common.connectionError
+ );
+ }
+
+ const warehouseListRes = await fetch("/api/products/warehouses?page=1&size=100", {
+ headers: {
+ "x-auth-token": getToken(),
+ },
+ });
+
+ const warehouseListData = await warehouseListRes.json().catch(() => ({}));
+ const warehouseRows: WarehouseListRow[] = Array.isArray(warehouseListData?.rows)
+ ? warehouseListData.rows
+ : [];
+
+ const normalizedWarehouseAddress = payload.warehouse.address.trim().toLowerCase();
+ const normalizedWarehouseCity = payload.warehouse.city.trim().toLowerCase();
+ const normalizedWarehouseProvince = payload.warehouse.province.trim().toLowerCase();
+ const normalizedWarehousePostalCode = payload.warehouse.postalCode.trim().toLowerCase();
+
+ const autogeneratedWarehouse = warehouseRows.find((item) => {
+ const normalizedItemAddress = (item.address || "").trim().toLowerCase();
+ const normalizedItemCity = (item.city || "").trim().toLowerCase();
+ const normalizedItemProvince = (item.province || "").trim().toLowerCase();
+ const normalizedItemPostalCode = (item.postalCode || "").trim().toLowerCase();
+ const hasNoConfiguredName = !item.name || !item.name.trim();
+ const hasNoConfiguredType = !item.warehouseType || !item.warehouseType.trim();
+ const sameAddress =
+ normalizedItemAddress === normalizedWarehouseAddress &&
+ normalizedItemCity === normalizedWarehouseCity &&
+ normalizedItemProvince === normalizedWarehouseProvince &&
+ normalizedItemPostalCode === normalizedWarehousePostalCode;
+
+ return Boolean(item.id) && (sameAddress || hasNoConfiguredName || hasNoConfiguredType);
+ });
+
+ const warehouseEndpoint = autogeneratedWarehouse?.id
+ ? `/api/warehouses/${autogeneratedWarehouse.id}`
+ : "/api/warehouses";
+ const warehouseMethod = autogeneratedWarehouse?.id ? "PUT" : "POST";
+
+ const warehouseRes = await fetch(warehouseEndpoint, {
+ method: warehouseMethod,
+ headers: {
+ "Content-Type": "application/json",
+ "x-auth-token": getToken(),
+ },
+ body: JSON.stringify(payload.warehouse),
+ });
+
+ const warehouseData = await warehouseRes.json().catch(() => ({}));
+ if (!warehouseRes.ok) {
+ throw new Error(
+ warehouseData?.error ||
+ warehouseData?.responseDesc ||
+ common.connectionError
+ );
+ }
+
+ sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY);
+ sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY);
+ sessionStorage.removeItem("selectedPlan");
+ router.push("/onboarding/success");
} catch (err) {
- setError(err instanceof Error ? err.message : sd.genericError);
+ setError(
+ err instanceof Error ? err.message : common.connectionError
+ );
} finally {
setSubmitting(false);
}
@@ -299,25 +416,6 @@ export default function StoreDetailPage() {
/>
-
-
-
-
-
diff --git a/src/app/admin/review/[productId]/page.tsx b/src/app/admin/review/[productId]/page.tsx
index da90e6d..2236bf2 100644
--- a/src/app/admin/review/[productId]/page.tsx
+++ b/src/app/admin/review/[productId]/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import { ProductVariantShowcase } from "@/components/product-variant-showcase";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -50,7 +51,7 @@ function formatMeasurementLabel(
index: number
) {
const parts = [measurement.measurementType, measurement.measurementValue].filter(Boolean);
- return parts.length > 0 ? parts.join(" - ") : `Measurement ${index + 1}`;
+ return parts.length > 0 ? parts.join(" - ") : `Ukuran ${index + 1}`;
}
interface ReviewWarehouse {
@@ -86,6 +87,11 @@ interface ReviewModel extends ReviewMeasurement {
productMeasurements?: ReviewMeasurement[];
}
+interface ReviewInfoItem {
+ paramName: string;
+ paramValue: string;
+}
+
interface ReviewProductData {
name?: string;
description?: string;
@@ -100,6 +106,8 @@ interface ReviewProductData {
productModels?: ReviewModel[];
productKeyWords?: string[];
productFeatures?: string[];
+ productInformations?: ReviewInfoItem[];
+ categoryInformations?: ReviewInfoItem[];
subCategory?: {
name?: string;
category?: {
@@ -162,39 +170,43 @@ function ModelCard({
const modelDimension = formatDimension(model.length, model.width, model.height, model.dimensionType);
return (
-
+
{imgUrl(model.imageId) && (
// eslint-disable-next-line @next/next/no-img-element
-
!})
+
!})
)}
{model.name || `Model ${index + 1}`}
{changed && (
- Updated
+ Diperbarui
)}
{hasMeasurements && (
- {measurements.length} measurement variation(s)
+ {measurements.length} variasi ukuran
)}
|
-
|
-
|
-
|
- {model.isConfigurePromotionPrice && !hasMeasurements && (
-
|
- )}
+ {!hasMeasurements ? (
+ <>
+
|
+
|
+
|
+ {model.isConfigurePromotionPrice && (
+
|
+ )}
+ >
+ ) : null}
{!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && (
-
-
Warehouse & Stok
+
+
Warehouse & Stok
{model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
- {[warehouse.city, warehouse.province].filter(Boolean).join(", ")}
+ {[warehouse.city, warehouse.province].filter(Boolean).join(", ")}
{warehouse.stock ?? 0} unit
))}
@@ -202,7 +214,7 @@ function ModelCard({
)}
{hasMeasurements && (
-
+
{measurements.map((measurement: ReviewMeasurement, measurementIndex: number) => {
const measurementPrice =
formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-";
@@ -221,7 +233,7 @@ function ModelCard({
return (
{formatMeasurementLabel(measurement, measurementIndex)}
@@ -233,11 +245,11 @@ function ModelCard({
)}
{Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && (
-
-
Warehouse & Stok
+
+
Warehouse & Stok
{measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
- {[warehouse.city, warehouse.province].filter(Boolean).join(", ")}
+ {[warehouse.city, warehouse.province].filter(Boolean).join(", ")}
{warehouse.stock ?? 0} unit
))}
@@ -258,13 +270,39 @@ function Row({ label, value }: { label: string; value?: string | number | boolea
if (value === "" || value === undefined || value === null) return null;
const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value);
return (
-
-
{label}
+
+ {label}
{display}
);
}
+function ToggleBadge({ label, value }: { label: string; value: boolean }) {
+ return (
+
+ {label}
+
+ {value ? "Ya" : "Tidak"}
+
+
+ );
+}
+
+function SectionHeader({ step, title }: { step: string; title: string }) {
+ return (
+
+ );
+}
+
function SectionCard({
title,
accent,
@@ -277,14 +315,14 @@ function SectionCard({
children: React.ReactNode;
}) {
return (
-
-
-
+
+
+
{title}
{changed && (
- Updated
+ Diperbarui
)}
@@ -355,6 +393,12 @@ function ProductColumn({
const images = Array.isArray(product.productImages) ? product.productImages : [];
const features = Array.isArray(product.productFeatures) ? product.productFeatures : [];
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : [];
+ const productInfos = Array.isArray(product.productInformations)
+ ? product.productInformations.filter((item) => item.paramName && item.paramValue)
+ : [];
+ const categoryInfos = Array.isArray(product.categoryInformations)
+ ? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
+ : [];
const allImages: string[] = [
...(product.imageId ? [product.imageId] : []),
...images
@@ -368,21 +412,19 @@ function ProductColumn({
return (
- {/* Column label */}
-
+
{label}
- {/* Images */}
{allImages.length > 0 && (
-
+
{allImages.map((imageId: string, i: number) => {
const url = imgUrl(imageId);
if (!url) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
-

+

);
})}
@@ -408,33 +450,30 @@ function ProductColumn({
|
-
|
-
|
+
|
+
|
- {product.isPreOrder &&
|
}
-
|
+ {product.isPreOrder &&
|
}
+
|
{/* Features */}
{features.length > 0 && (
-
+
{features.map((f: string, i: number) => (
-
-
- check_circle
- {f}
-
+ {f}
))}
-
+
)}
{/* Keywords */}
{keywords.length > 0 && (
-
+
{keywords.map((k: string) => (
- {k}
+ {k}
))}
@@ -443,26 +482,21 @@ function ProductColumn({
{/* Models */}
{models.length > 0 && (
-
- {models.map((model: ReviewModel, index: number) => (
-
- ))}
-
+
+ [warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
+ }
+ />
)}
{/* Compliance */}
{product.complianceInformation && (
-
+
-
-
+
+
)}
@@ -614,6 +648,25 @@ function AdminReviewDetailPageInner() {
);
+ const models = Array.isArray(product.productModels) ? product.productModels : [];
+ const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
+ const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords.filter(Boolean) : [];
+ const productInfos = Array.isArray(product.productInformations)
+ ? product.productInformations.filter((item) => item.paramName && item.paramValue)
+ : [];
+ const categoryInfos = Array.isArray(product.categoryInformations)
+ ? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
+ : [];
+ const allImages = [
+ ...(product.imageId ? [product.imageId] : []),
+ ...(Array.isArray(product.productImages)
+ ? product.productImages
+ .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
+ .map((item) => item.imageId)
+ .filter((value): value is string => Boolean(value))
+ : []),
+ ];
+
// ── Reject modal ────────────────────────────────────────────────────────
const rejectModal = showRejectModal && (
@@ -660,59 +713,320 @@ function AdminReviewDetailPageInner() {
<>
{!isReadonly ? rejectModal : null}
-
- {/* Header */}
-
+
+
+
+
-
- {/* Comparison notice */}
- {isComparison && (
-
-
compare_arrows
- Bandingkan perubahan yang diajukan seller (kiri) dengan data produk saat ini (kanan).
+
+
+
+
+
Kategori Utama
+
+ {product.subCategory?.category?.name || "—"}
+
+
+
+
Sub Kategori
+
+ {product.subCategory?.name || "—"}
+
+
+
+
Mode Review
+
+ {isComparison ? "Perbandingan update" : "Produk baru"}
+
+
+
+
Penjual
+
+ {product.seller?.name || "-"}
+
+
- )}
+
- {/* Content — 1 column (isNew) or 2 columns (update) */}
{isComparison ? (
-
-
-
+
+
+ compare_arrows
+ Bandingkan perubahan yang diajukan seller dengan data produk yang saat ini live.
+
+
+ ) : null}
+
+ {isComparison ? (
+
) : (
-
+ <>
+
+
+
+
+
Kategori Utama
+
{product.subCategory?.category?.name || "—"}
+
+
+
Sub Kategori
+
{product.subCategory?.name || "—"}
+
+
+
+
+
+
+
+
+
+
Nama Produk
+
{product.name || "—"}
+
+
+
+
+
+
+
+ {product.isPreOrder ? (
+
+
Durasi Pre-order
+
{product.preOrderDay || "—"}
+
+ ) : null}
+
+
+
Deskripsi
+
{product.description || "—"}
+
+
+ {features.length ? (
+
+
Fitur Produk
+
+ {features.map((feature) => (
+
+ {feature}
+
+ ))}
+
+
+ ) : null}
+
+ {keywords.length ? (
+
+
Kata Kunci
+
+ {keywords.map((keyword) => (
+
+ {keyword}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ {allImages.length ? (
+
+ {allImages.map((imageId, index) => {
+ const src = imgUrl(imageId);
+ if (!src) return null;
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ );
+ })}
+
+ ) : (
+
+ Tidak ada gambar
+
+ )}
+
+
+ {product.seller ? (
+
+
+
+ {imgUrl(product.seller.imageId) ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
)
+ ) : (
+
+ storefront
+
+ )}
+
+
{product.seller.name || "-"}
+
ID: {product.seller.id || "-"}
+
+
+
+ ) : null}
+
+
+
+
+
+
+ [warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
+ }
+ />
+
+
+
+
+
+ {productInfos.length || categoryInfos.length ? (
+
+ {productInfos.length ? (
+
+
Informasi Produk
+
+ {productInfos.map((item) => (
+
+ ))}
+
+
+ ) : null}
+
+ {categoryInfos.length ? (
+
+
Informasi Kategori
+
+ {categoryInfos.map((item) => (
+
+ ))}
+
+
+ ) : null}
+
+ ) : (
+
+ Tidak ada informasi tambahan
+
+ )}
+
+
+
+
+ >
)}
- {/* Seller card (single, below both columns) */}
- {product.seller && (
-
-
Seller
+ {product.seller && isComparison ? (
+
+
{imgUrl(product.seller.imageId) ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -728,11 +1042,11 @@ function AdminReviewDetailPageInner() {
- )}
+ ) : null}
- {/* Action bar */}
{!isReadonly ? (
-
+
+
{actionSuccess && (
check_circle
@@ -745,32 +1059,36 @@ function AdminReviewDetailPageInner() {
{actionError}
)}
- {acting && (
-
- progress_activity
- Memproses review...
-
- )}
- {!actionSuccess && (
-
- { setShowRejectModal(true); setActionError(""); }}
- disabled={acting}
- className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
- >
- block
- {isComparison ? "Reject Update" : "Reject Product"}
-
- submitReview("accept")}
- disabled={acting}
- className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
- >
- check_circle
- {acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
-
-
- )}
+
+
+ {acting ? (
+
+ progress_activity
+ Memproses review...
+
+ ) : null}
+
+ {!actionSuccess ? (
+
+ { setShowRejectModal(true); setActionError(""); }}
+ disabled={acting}
+ className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
+ >
+ block
+ {isComparison ? "Tolak Update" : "Tolak Produk"}
+
+ submitReview("accept")}
+ disabled={acting}
+ className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
+ >
+ check_circle
+ {acting ? "Memproses..." : isComparison ? "Setujui Update" : "Setujui Produk"}
+
+
+ ) : null}
+
) : null}
@@ -780,7 +1098,7 @@ function AdminReviewDetailPageInner() {
export default function AdminReviewDetailPage() {
return (
-
Loading review detail...}>
+
Memuat detail review...}>
);
diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts
index 5f9a352..dd18033 100644
--- a/src/app/api/products/route.ts
+++ b/src/app/api/products/route.ts
@@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
const endpointMap: Record
= {
draft: "/api/v1.0/seller/draft/product",
- "in-review": "/api/v1.0/product/review",
+ "in-review": "/api/v1.0/seller/product/review",
"international-market": "/api/v1.0/seller/international/product",
"local-market": "/api/v1.0/seller/local/product",
"out-of-stock": "/api/v1.0/seller/outofstock/product",
diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx
new file mode 100644
index 0000000..15abde8
--- /dev/null
+++ b/src/app/help/page.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { LanguageToggle } from "@/components/language-toggle";
+
+const supportEmail = "admin@inatrading.co.id";
+
+const categories = [
+ {
+ icon: "lock_reset",
+ title: "Akses Akun",
+ description:
+ "Panduan login, reset password, dan pemulihan akses akun seller atau buyer.",
+ },
+ {
+ icon: "storefront",
+ title: "Onboarding Seller",
+ description:
+ "Bantuan pengisian data bisnis, detail toko, dan tahap aktivasi akun seller.",
+ },
+ {
+ icon: "inventory_2",
+ title: "Produk & Stok",
+ description:
+ "Informasi pengelolaan produk, harga, warehouse, dan alur review produk.",
+ },
+ {
+ icon: "verified_user",
+ title: "Verifikasi & Kepatuhan",
+ description:
+ "Dokumen legal, verifikasi identitas, dan kebutuhan kepatuhan perdagangan.",
+ },
+];
+
+const quickAnswers = [
+ {
+ question: "Saya tidak menerima email reset password.",
+ answer:
+ "Periksa folder spam/junk terlebih dahulu. Jika masih tidak ada, hubungi admin agar kami bisa bantu verifikasi akun Anda secara manual.",
+ },
+ {
+ question: "Saya tidak bisa menyelesaikan onboarding seller.",
+ answer:
+ "Pastikan data bisnis, dokumen, dan detail toko sudah terisi lengkap. Jika ada field yang membingungkan, kirim email beserta screenshot error yang muncul.",
+ },
+ {
+ question: "Saya sudah login tetapi diarahkan ke onboarding lagi.",
+ answer:
+ "Itu biasanya terjadi saat profil seller belum lengkap. Lengkapi data bisnis dan profil toko sampai semua tahap onboarding selesai.",
+ },
+];
+
+export default function PublicHelpPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Public Help Center
+
+
+ Bantuan cepat tanpa perlu login.
+
+
+ Gunakan halaman ini untuk bantuan akses akun, onboarding seller,
+ dan pertanyaan umum seputar penggunaan Ina Trading.
+
+
+
+
+
+ support_agent
+
+
+ Butuh bantuan langsung?
+
+
+ Untuk kendala akun atau onboarding, kirim email ke admin kami
+ dan sertakan email akun, nomor HP, serta screenshot jika ada.
+
+
+ mail
+ Email Admin
+
+
+ {supportEmail}
+
+
+
+
+
+
+
+
+
+
+ Fokus Bantuan
+
+
+ Area yang paling sering ditanyakan
+
+
+
+
+
+
+ {categories.map((item) => (
+
+
+ {item.icon}
+
+
+ {item.title}
+
+
+ {item.description}
+
+
+ ))}
+
+
+
+
+
+
+
+ Quick Answers
+
+
+ Pertanyaan umum
+
+
+ {quickAnswers.map((item) => (
+
+
+ {item.question}
+
+
+ {item.answer}
+
+
+ ))}
+
+
+
+
+
+ Contact
+
+
+ Hubungi tim admin
+
+
+ Jika bantuan mandiri belum cukup, kirim email dengan detail
+ kendala Anda. Default email app di perangkat Anda akan terbuka.
+
+
+
+
+ Email tujuan
+
+
+ {supportEmail}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
new file mode 100644
index 0000000..f394496
--- /dev/null
+++ b/src/app/privacy/page.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { LanguageToggle } from "@/components/language-toggle";
+
+const sections = [
+ {
+ title: "Data yang Kami Kumpulkan",
+ body:
+ "Kami dapat mengumpulkan data identitas, data kontak, informasi bisnis, dokumen pendukung, serta informasi teknis yang diperlukan untuk pengoperasian platform Ina Trading.",
+ },
+ {
+ title: "Cara Kami Menggunakan Data",
+ body:
+ "Data digunakan untuk verifikasi akun, pemrosesan onboarding, pengelolaan transaksi, dukungan pelanggan, peningkatan layanan, dan pemenuhan kewajiban regulasi yang berlaku.",
+ },
+ {
+ title: "Penyimpanan dan Perlindungan",
+ body:
+ "Kami menerapkan kontrol teknis dan administratif yang wajar untuk menjaga kerahasiaan, integritas, dan ketersediaan data yang diproses melalui platform kami.",
+ },
+ {
+ title: "Berbagi dengan Pihak Ketiga",
+ body:
+ "Data hanya dibagikan sejauh diperlukan untuk operasional platform, verifikasi, layanan logistik, kewajiban hukum, atau saat diwajibkan oleh regulator yang berwenang.",
+ },
+];
+
+export default function PrivacyPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ help_outline
+ Bantuan
+
+
+
+
+
+
+ Public Policy
+
+
+ Kebijakan Privasi
+
+
+ Halaman ini menjelaskan secara ringkas bagaimana Ina Trading mengelola
+ dan melindungi data pengguna tanpa memerlukan login terlebih dahulu.
+
+
+
+
+
+
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.body}
+
+
+ ))}
+
+
+
+
+ Jika Anda membutuhkan klarifikasi lebih lanjut terkait privasi data,
+ silakan kunjungi halaman bantuan publik atau hubungi tim admin melalui
+ email resmi Ina Trading.
+
+
+
+ support_agent
+ Buka Bantuan
+
+
+ gavel
+ Lihat Syarat
+
+
+
+
+
+ );
+}
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
new file mode 100644
index 0000000..9f60adc
--- /dev/null
+++ b/src/app/terms/page.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { LanguageToggle } from "@/components/language-toggle";
+
+const sections = [
+ {
+ title: "Penggunaan Layanan",
+ body:
+ "Pengguna bertanggung jawab atas keakuratan data akun, kepatuhan terhadap peraturan perdagangan yang berlaku, dan penggunaan platform secara sah serta tidak menyesatkan.",
+ },
+ {
+ title: "Akun dan Keamanan",
+ body:
+ "Pengguna wajib menjaga kerahasiaan kredensial akun. Setiap aktivitas yang terjadi melalui akun pengguna menjadi tanggung jawab pemilik akun kecuali ditentukan lain oleh hukum yang berlaku.",
+ },
+ {
+ title: "Konten dan Dokumen",
+ body:
+ "Semua dokumen, gambar, dan data yang diunggah harus merupakan milik pengguna atau telah diotorisasi secara sah untuk digunakan dalam proses operasional Ina Trading.",
+ },
+ {
+ title: "Pembatasan Tanggung Jawab",
+ body:
+ "Ina Trading berupaya menyediakan layanan sebaik mungkin, namun tidak menjamin layanan bebas gangguan setiap saat dan berhak melakukan perubahan, peninjauan, atau pembatasan akses sesuai kebutuhan operasional dan kepatuhan.",
+ },
+];
+
+export default function TermsPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ help_outline
+ Bantuan
+
+
+
+
+
+
+ Public Policy
+
+
+ Syarat & Ketentuan
+
+
+ Ringkasan ini disediakan untuk akses publik tanpa login agar
+ pengguna dapat memahami kerangka penggunaan platform Ina Trading.
+
+
+
+
+
+
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.body}
+
+
+ ))}
+
+
+
+
+ Dengan melanjutkan penggunaan platform, Anda dianggap memahami dan
+ menyetujui kerangka syarat dasar ini. Untuk bantuan lebih lanjut,
+ gunakan halaman bantuan publik.
+
+
+
+ support_agent
+ Buka Bantuan
+
+
+ shield_lock
+ Lihat Privasi
+
+
+
+
+
+ );
+}
diff --git a/src/components/product-variant-showcase.tsx b/src/components/product-variant-showcase.tsx
new file mode 100644
index 0000000..c076394
--- /dev/null
+++ b/src/components/product-variant-showcase.tsx
@@ -0,0 +1,317 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ buildMeasurementLabel,
+ formatDimension,
+ formatMoney,
+ formatWeight,
+ getAllProductImageIds,
+ getEffectiveDimensionLabel,
+ getEffectivePriceLabel,
+ getEffectiveWeightLabel,
+ getModelMeasurements,
+ getModelPriceLabel,
+ modelHasMeasurements,
+ type VariantMeasurementLike,
+ type VariantModelLike,
+ type VariantProductLike,
+ type VariantWarehouseLike,
+} from "@/lib/product-variants";
+
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
+
+function imgUrl(id?: string | null) {
+ if (!id) return null;
+ if (id.startsWith("http")) return id;
+ return `${API_BASE}/api/v1.0/file/image/${id}`;
+}
+
+function DetailRow({ label, value }: { label: string; value?: string | number | null }) {
+ if (value === "" || value === undefined || value === null) return null;
+ return (
+
+ {label}
+ {String(value)}
+
+ );
+}
+
+type Labels = {
+ summaryTitle: string;
+ structureTitle: string;
+ productPrice: string;
+ productWeight: string;
+ productDimension: string;
+ modelLabel: string;
+ measurementLabel: string;
+ selectedDetailTitle: string;
+ noMeasurement: string;
+ noModels: string;
+ price: string;
+ weight: string;
+ dimension: string;
+ promoPrice: string;
+ sku: string;
+ stock: string;
+ variants: string;
+ varied: string;
+};
+
+const defaultLabels: Labels = {
+ summaryTitle: "Ringkasan Varian",
+ structureTitle: "Struktur Varian",
+ productPrice: "Harga Produk",
+ productWeight: "Berat Produk",
+ productDimension: "Dimensi Produk",
+ modelLabel: "Model",
+ measurementLabel: "Ukuran",
+ selectedDetailTitle: "Detail Varian Terpilih",
+ noMeasurement: "Model ini tidak memiliki ukuran tambahan",
+ noModels: "Tidak ada model produk",
+ price: "Harga",
+ weight: "Berat",
+ dimension: "Dimensi",
+ promoPrice: "Harga Promo",
+ sku: "SKU",
+ stock: "Warehouse & Stok",
+ variants: "variasi ukuran",
+ varied: "Bervariasi",
+};
+
+function getWarehouseLabel(
+ warehouse: VariantWarehouseLike,
+ resolver?: (warehouse: VariantWarehouseLike) => string
+) {
+ if (resolver) return resolver(warehouse);
+ return [warehouse.name, warehouse.city, warehouse.province].filter(Boolean).join(", ") || warehouse.id || "-";
+}
+
+export function ProductVariantShowcase({
+ product,
+ warehouseLabelResolver,
+ labels,
+}: {
+ product: VariantProductLike;
+ warehouseLabelResolver?: (warehouse: VariantWarehouseLike) => string;
+ labels?: Partial;
+}) {
+ const l = { ...defaultLabels, ...labels };
+ const models = Array.isArray(product.productModels) ? product.productModels : [];
+ const productImages = getAllProductImageIds(product);
+ const [selectedModelIndex, setSelectedModelIndex] = useState(0);
+ const [selectedMeasurementIndex, setSelectedMeasurementIndex] = useState(0);
+
+ useEffect(() => {
+ setSelectedModelIndex(0);
+ setSelectedMeasurementIndex(0);
+ }, [product]);
+
+ const selectedModel = models[selectedModelIndex] || null;
+ const measurements = selectedModel ? getModelMeasurements(selectedModel) : [];
+ const hasMeasurements = selectedModel ? modelHasMeasurements(selectedModel) : false;
+ const selectedMeasurement = hasMeasurements ? measurements[selectedMeasurementIndex] || measurements[0] || null : null;
+ const selectedImageId =
+ (selectedModel?.imageId as string | undefined) ||
+ productImages[0] ||
+ null;
+ const selectedImageUrl = imgUrl(selectedImageId);
+
+ const selectedPrice = selectedMeasurement
+ ? formatMoney(selectedMeasurement.price, selectedMeasurement.currency || selectedModel?.currency)
+ : formatMoney(selectedModel?.price, selectedModel?.currency);
+ const selectedWeight = selectedMeasurement
+ ? formatWeight(selectedMeasurement.weight, selectedMeasurement.weightType || selectedModel?.weightType)
+ : formatWeight(selectedModel?.weight, selectedModel?.weightType);
+ const selectedDimension = selectedMeasurement
+ ? formatDimension(
+ selectedMeasurement.length,
+ selectedMeasurement.width,
+ selectedMeasurement.height,
+ selectedMeasurement.dimensionType || selectedModel?.dimensionType
+ )
+ : formatDimension(
+ selectedModel?.length,
+ selectedModel?.width,
+ selectedModel?.height,
+ selectedModel?.dimensionType
+ );
+ const selectedPromotionPrice =
+ selectedMeasurement?.isConfigurePromotionPrice
+ ? formatMoney(
+ selectedMeasurement.promotionPrice,
+ selectedMeasurement.promotionCurrency || selectedMeasurement.currency || selectedModel?.currency
+ )
+ : selectedModel?.isConfigurePromotionPrice
+ ? formatMoney(selectedModel.promotionPrice, selectedModel.promotionCurrency || selectedModel.currency)
+ : undefined;
+ const selectedWarehouses = selectedMeasurement?.warehouses || selectedModel?.warehouses || [];
+
+ if (models.length === 0) {
+ return (
+
+ {l.noModels}
+
+ );
+ }
+
+ return (
+
+
+
+
{l.summaryTitle}
+
+
+
{l.productPrice}
+
{getEffectivePriceLabel(models) || "-"}
+
+
+
{l.productWeight}
+
{getEffectiveWeightLabel(models) || l.varied}
+
+
+
{l.productDimension}
+
{getEffectiveDimensionLabel(models) || l.varied}
+
+
+
{l.structureTitle}
+
+ {models.length} {l.modelLabel.toLowerCase()} •{" "}
+ {models.reduce((total, model) => total + getModelMeasurements(model).length, 0)} {l.variants}
+
+
+
+
+
+
+
+
+
+ {selectedImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
image
+ )}
+
+ {productImages.length > 1 ? (
+
+ {productImages.slice(0, 4).map((imageId, index) => {
+ const image = imgUrl(imageId);
+ if (!image) return null;
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ );
+ })}
+
+ ) : null}
+
+
+
+
+
{l.modelLabel}
+
+ {models.map((model, index) => {
+ const active = index === selectedModelIndex;
+ const measurementCount = getModelMeasurements(model).length;
+ const thumb = imgUrl(model.imageId || selectedImageId);
+ return (
+
{
+ setSelectedModelIndex(index);
+ setSelectedMeasurementIndex(0);
+ }}
+ className={`flex items-center gap-3 rounded-2xl border p-3 text-left transition-all ${
+ active
+ ? "border-primary bg-primary/5 shadow-sm"
+ : "border-outline-variant/10 bg-surface-container-low hover:border-primary/30"
+ }`}
+ >
+
+ {thumb ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
image
+ )}
+
+
+
{model.name || `Model ${index + 1}`}
+
{getModelPriceLabel(model)}
+
+ {measurementCount > 0 ? `${measurementCount} ${l.variants}` : "Tanpa variasi ukuran"}
+
+
+
+ );
+ })}
+
+
+
+
+
{l.measurementLabel}
+ {hasMeasurements ? (
+
+ {measurements.map((measurement, index) => {
+ const active = index === selectedMeasurementIndex;
+ return (
+ setSelectedMeasurementIndex(index)}
+ className={`rounded-full px-3 py-2 text-xs font-black transition-colors ${
+ active
+ ? "bg-primary text-white"
+ : "bg-surface-container-low text-on-surface hover:bg-primary/10 hover:text-primary"
+ }`}
+ >
+ {buildMeasurementLabel(measurement, index)}
+
+ );
+ })}
+
+ ) : (
+
+ {l.noMeasurement}
+
+ )}
+
+
+
+
{l.selectedDetailTitle}
+
+
+ {selectedMeasurement ? (
+
+ ) : null}
+
+
+
+
+
+
+ {selectedWarehouses.length > 0 ? (
+
+
{l.stock}
+
+ {selectedWarehouses.map((warehouse: VariantWarehouseLike, index) => (
+
+ {getWarehouseLabel(warehouse, warehouseLabelResolver)}
+ {warehouse.stock ?? 0} unit
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/product-variants.ts b/src/lib/product-variants.ts
new file mode 100644
index 0000000..4b8d7b8
--- /dev/null
+++ b/src/lib/product-variants.ts
@@ -0,0 +1,206 @@
+export interface VariantWarehouseLike {
+ id?: string | number | null;
+ name?: string;
+ address?: string;
+ city?: string;
+ province?: string;
+ country?: string;
+ stock?: string | number | null;
+}
+
+export interface VariantMeasurementLike {
+ id?: string | number | null;
+ productMeasurementId?: string | number | null;
+ measurementType?: string | null;
+ measurementValue?: string | null;
+ price?: string | number | null;
+ currency?: string | null;
+ weight?: string | number | null;
+ weightType?: string | null;
+ length?: string | number | null;
+ width?: string | number | null;
+ height?: string | number | null;
+ dimensionType?: string | null;
+ isConfigurePromotionPrice?: boolean | null;
+ promotionPrice?: string | number | null;
+ promotionCurrency?: string | null;
+ warehouses?: VariantWarehouseLike[] | null;
+}
+
+export interface VariantModelLike extends VariantMeasurementLike {
+ productModelId?: string | number | null;
+ name?: string | null;
+ sku?: string | null;
+ imageId?: string | null;
+ warehouses?: VariantWarehouseLike[] | null;
+ productMeasurements?: VariantMeasurementLike[] | null;
+}
+
+export interface VariantProductLike {
+ imageId?: string | null;
+ productImages?: Array<{ sequence?: number | null; imageId?: string | null }> | null;
+ productModels?: VariantModelLike[] | null;
+}
+
+export interface EffectiveVariantPoint {
+ price?: number;
+ currency?: string | null;
+ weight?: number;
+ weightType?: string | null;
+ dimension?: string;
+ dimensionType?: string | null;
+}
+
+function toFiniteNumber(value?: string | number | null) {
+ if (value === "" || value === undefined || value === null) return undefined;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+export function formatMoney(value?: string | number | null, currency?: string | null) {
+ const amount = toFiniteNumber(value);
+ if (amount === undefined) return undefined;
+ return `${currency || "IDR"} ${amount.toLocaleString("id-ID")}`;
+}
+
+export function formatMoneyRange(values: number[], currency?: string | null) {
+ if (values.length === 0) return undefined;
+ const sorted = [...values].sort((a, b) => a - b);
+ const min = sorted[0];
+ const max = sorted[sorted.length - 1];
+ if (min === max) return formatMoney(min, currency);
+ return `${formatMoney(min, currency)} - ${formatMoney(max, currency)}`;
+}
+
+export function formatWeight(value?: string | number | null, weightType?: string | null) {
+ const amount = toFiniteNumber(value);
+ if (amount === undefined) return undefined;
+ return `${amount} ${weightType || ""}`.trim();
+}
+
+export function formatDimension(
+ length?: string | number | null,
+ width?: string | number | null,
+ height?: string | number | null,
+ dimensionType?: string | null
+) {
+ const parts = [length, width, height]
+ .map((value) => toFiniteNumber(value))
+ .filter((value): value is number => value !== undefined);
+ if (parts.length === 0) return undefined;
+ return `${parts.join(" × ")} ${dimensionType || ""}`.trim();
+}
+
+export function buildMeasurementLabel(
+ measurement: { measurementType?: string | null; measurementValue?: string | null },
+ index: number
+) {
+ const parts = [measurement.measurementType, measurement.measurementValue]
+ .map((value) => String(value || "").trim())
+ .filter(Boolean);
+ return parts.length > 0 ? parts.join(" - ") : `Ukuran ${index + 1}`;
+}
+
+export function getSortedProductImages(product: VariantProductLike) {
+ const gallery = Array.isArray(product.productImages) ? product.productImages : [];
+ return gallery
+ .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
+ .map((item) => item.imageId)
+ .filter((value): value is string => typeof value === "string" && value.length > 0);
+}
+
+export function getAllProductImageIds(product: VariantProductLike) {
+ return [
+ ...(product.imageId ? [product.imageId] : []),
+ ...getSortedProductImages(product),
+ ];
+}
+
+export function getModelMeasurements(model: VariantModelLike) {
+ return Array.isArray(model.productMeasurements)
+ ? model.productMeasurements
+ : [];
+}
+
+export function modelHasMeasurements(model: VariantModelLike) {
+ return getModelMeasurements(model).length > 0;
+}
+
+export function getModelEffectivePoints(model: VariantModelLike): EffectiveVariantPoint[] {
+ const measurements = getModelMeasurements(model);
+
+ if (measurements.length > 0) {
+ return measurements.map((measurement) => ({
+ price: toFiniteNumber(measurement.price),
+ currency: measurement.currency || model.currency || "IDR",
+ weight: toFiniteNumber(measurement.weight),
+ weightType: measurement.weightType || model.weightType,
+ dimension: formatDimension(
+ measurement.length,
+ measurement.width,
+ measurement.height,
+ measurement.dimensionType
+ ),
+ dimensionType: measurement.dimensionType || model.dimensionType,
+ }));
+ }
+
+ return [
+ {
+ price: toFiniteNumber(model.price),
+ currency: model.currency || "IDR",
+ weight: toFiniteNumber(model.weight),
+ weightType: model.weightType,
+ dimension: formatDimension(model.length, model.width, model.height, model.dimensionType),
+ dimensionType: model.dimensionType,
+ },
+ ];
+}
+
+export function getProductEffectivePoints(models: VariantModelLike[]) {
+ return models.flatMap((model) => getModelEffectivePoints(model));
+}
+
+export function getEffectivePriceLabel(models: VariantModelLike[]) {
+ const points = getProductEffectivePoints(models);
+ const prices = points
+ .map((point) => point.price)
+ .filter((value): value is number => value !== undefined);
+ const currency = points.find((point) => point.currency)?.currency || "IDR";
+ return formatMoneyRange(prices, currency);
+}
+
+export function getEffectiveWeightLabel(models: VariantModelLike[]) {
+ const points = getProductEffectivePoints(models);
+ const weights = points
+ .map((point) => point.weight)
+ .filter((value): value is number => value !== undefined);
+ const weightType = points.find((point) => point.weightType)?.weightType || "";
+ if (weights.length === 0) return undefined;
+ const uniqueWeightTypes = new Set(points.map((point) => point.weightType || "").filter(Boolean));
+ if (uniqueWeightTypes.size > 1) return "Bervariasi";
+ const sorted = [...weights].sort((a, b) => a - b);
+ const min = sorted[0];
+ const max = sorted[sorted.length - 1];
+ return min === max
+ ? `${min} ${weightType}`.trim()
+ : `${min} - ${max} ${weightType}`.trim();
+}
+
+export function getEffectiveDimensionLabel(models: VariantModelLike[]) {
+ const dimensions = getProductEffectivePoints(models)
+ .map((point) => point.dimension)
+ .filter((value): value is string => Boolean(value));
+ if (dimensions.length === 0) return undefined;
+ const uniqueDimensions = Array.from(new Set(dimensions));
+ return uniqueDimensions.length === 1 ? uniqueDimensions[0] : "Bervariasi";
+}
+
+export function getModelPriceLabel(model: VariantModelLike) {
+ const points = getModelEffectivePoints(model);
+ const prices = points
+ .map((point) => point.price)
+ .filter((value): value is number => value !== undefined);
+ const currency = points.find((point) => point.currency)?.currency || model.currency || "IDR";
+ return formatMoneyRange(prices, currency) || "-";
+}
diff --git a/src/lib/translations/en.ts b/src/lib/translations/en.ts
index 4dbc4f4..0faa114 100644
--- a/src/lib/translations/en.ts
+++ b/src/lib/translations/en.ts
@@ -19,7 +19,7 @@ export const en = {
emailOrPhone: "Email or Phone Number",
password: "Password",
forgotPassword: "Forgot password?",
- rememberDevice: "Remember this device for 30 days",
+ rememberMe: "Remember me",
submit: "Sign In",
submitting: "Processing...",
noAccount: "Don't have an account?",
@@ -80,7 +80,7 @@ export const en = {
verifyFail: "Verification failed",
registerFail: "Seller registration failed",
successSeller:
- "OTP valid and seller account created. Redirecting to dashboard...",
+ "OTP valid and seller account created. Redirecting to onboarding...",
successBuyer:
"OTP successfully verified. Redirecting to the next step...",
securityTitle: "Institutional-Grade Security",
diff --git a/src/lib/translations/id.ts b/src/lib/translations/id.ts
index b24c6ef..bb1f6c3 100644
--- a/src/lib/translations/id.ts
+++ b/src/lib/translations/id.ts
@@ -19,7 +19,7 @@ export const id = {
emailOrPhone: "Email atau Nomor HP",
password: "Password",
forgotPassword: "Lupa password?",
- rememberDevice: "Ingat perangkat ini selama 30 hari",
+ rememberMe: "Ingat saya",
submit: "Masuk",
submitting: "Memproses...",
noAccount: "Belum punya akun?",
@@ -81,7 +81,7 @@ export const id = {
verifyFail: "Verifikasi gagal",
registerFail: "Registrasi seller gagal",
successSeller:
- "OTP valid dan akun seller berhasil dibuat. Mengalihkan ke dashboard...",
+ "OTP valid dan akun seller berhasil dibuat. Mengalihkan ke onboarding...",
successBuyer:
"OTP berhasil diverifikasi. Mengalihkan ke langkah berikutnya...",
securityTitle: "Keamanan Tingkat Institusional",