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

198
HANDOFF.md Normal file
View File

@ -0,0 +1,198 @@
# Handoff
Project: `ina-trading-web`
Current branch: `main`
Latest verified commit: `b266047`
## Summary
This codebase has recent updates around product creation, edit, review, detail, admin review, stock/price editing, and backend request logging.
The latest build was verified successfully with:
```bash
npm run build
```
## Recent Commits
- `e9a0cd0` `Update product review, measurement, and backend logging flows`
- `b266047` `Fix TypeScript build errors in product detail and admin review`
## Main Changes
### 1. Backend proxy logging
Files:
- `src/lib/backend-fetch-logger.ts`
- `src/instrumentation.ts`
- `.env.local` uses `DEBUG_BACKEND_PROXY=true` locally
Behavior:
- Logs server-side `fetch` requests to backend
- Logs request method, URL, headers, body preview
- Logs response status, duration, headers, body preview
- Redacts authorization header
Use this when tracing frontend-to-backend failures in local dev.
## 2. Product measurement mode on add product
Files:
- `src/app/(dashboard)/products/new/pricing/page.tsx`
- `src/lib/use-product-submit.ts`
- `src/app/(dashboard)/products/new/review/page.tsx`
Behavior:
- Each model has a `Product Measurement` switch
- When enabled:
- nested measurements are shown
- model-level price, currency, weight, dimensions, promotion, packaging, and warehouse stock are disabled
- at least 1 measurement row is required
- Submit payload uses `model.hasMeasurements`
- When measurement mode is active, model-level numeric fields are zeroed and model warehouses are omitted
## 3. Product measurement mode on edit product
File:
- `src/app/(dashboard)/products/[productId]/edit/page.tsx`
Behavior:
- Matches add-product measurement behavior
- Uses explicit `hasMeasurements` state instead of guessing from array length
- Disables model-level fields when measurement mode is on
## 4. Seller product review page improvements
File:
- `src/app/(dashboard)/products/new/review/page.tsx`
Behavior:
- Review now renders actual product thumbnails using backend file URLs
- Previously it only showed image indicators, which was confusing
- Review also reflects measurement-based pricing correctly
## 5. Product list page improvements
File:
- `src/app/(dashboard)/products/page.tsx`
Behavior:
- Edit stock/price modal supports:
- choosing model
- choosing measurement if present
- choosing warehouse
- Warehouse labels use warehouse names from master warehouse API, not raw UUIDs
- Product row status badges now show explicit state like active, draft, review, unpublished, deleted, rejected
## 6. Product detail page improvements
File:
- `src/app/(dashboard)/products/[productId]/detail/page.tsx`
Behavior:
- Main category now resolves even when backend omits `subCategory.category.name`
- Warehouse display now uses warehouse master names instead of UUID slices
- TypeScript nullability issue for category resolution was fixed here
## 7. Admin review page improvements
File:
- `src/app/admin/review/[productId]/page.tsx`
Behavior:
- Review supports model + measurement structures
- New product review and compare review now handle measurement-driven price/weight/dimension/promo/stock
- Compare view highlights updated sections using backend compare response and `isUpdate`
- Product images are rendered using main image + gallery images
- TypeScript issue around `row.field` nullability was fixed here
## 8. Sidebar submenu reliability
Files:
- `src/components/product-submenu-nav.tsx`
- `src/components/admin-product-submenu-nav.tsx`
Behavior:
- Click behavior was hardened because submenu items were sometimes not navigating
- Navigation is explicit via `router.push(...)`
## Translation Files Touched
- `src/lib/translations/id.ts`
- `src/lib/translations/en.ts`
These include labels added for statuses and stock/price modal UI.
## Important Findings
### Admin review image issue
For at least one reviewed product, backend review payload returned:
- `image: null`
- `imageId: null`
- `productImages: []`
In that case frontend cannot render images. If an image is missing in review, verify backend review payload before debugging frontend.
### Edit product empty main image behavior
In edit product submit flow, empty `imageId` is currently sent as `undefined`, which means the field is omitted from JSON payload, not sent as `null` and not sent as empty string.
If backend requires explicit image removal via `null`, this behavior will need to be changed.
## Local Dev Notes
Run local dev:
```bash
npm run dev
```
Build verification:
```bash
npm run build
```
## Dev Server Update Guide
Based on current deployment notes, dev server update flow is:
```bash
sudo -iu inadev
cd ~/apps/ina-trading-web
git pull origin main
npm ci
npm run build
pm2 restart ina-trading-dev
pm2 save
sudo -iu inadev pm2 status
```
Optional logs:
```bash
sudo -iu inadev pm2 logs ina-trading-dev --lines 100
```
## Suggested Next Checks
- Verify whether backend expects `null` when main image is intentionally removed during edit
- Verify seller and admin review payload consistency for image fields
- Verify warehouse master API always contains all warehouse IDs referenced in product payloads
- If submenu click issue still appears, inspect layout overlays with browser tooling
- Consider adding field-level compare highlighting, not only section-level highlighting, in admin review compare page
## Files Most Likely To Be Relevant Next
- `src/app/(dashboard)/products/new/pricing/page.tsx`
- `src/app/(dashboard)/products/[productId]/edit/page.tsx`
- `src/app/(dashboard)/products/new/review/page.tsx`
- `src/app/(dashboard)/products/[productId]/detail/page.tsx`
- `src/app/(dashboard)/products/page.tsx`
- `src/app/admin/review/[productId]/page.tsx`
- `src/lib/use-product-submit.ts`
- `src/lib/backend-fetch-logger.ts`

View File

@ -1,15 +1,6 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: "/onboarding/:path*",
destination: "/dashboard",
permanent: false,
},
];
},
images: {
unoptimized: true,
remotePatterns: [

View File

@ -41,7 +41,7 @@ function AccountNotFoundContent() {
<div className="flex items-center gap-3">
<LanguageToggle />
<Link
href="/login"
href="/help"
className="hidden sm:flex text-secondary font-semibold hover:text-primary transition-colors items-center gap-2"
>
<span className="material-symbols-outlined text-lg">help_outline</span>
@ -104,12 +104,12 @@ function AccountNotFoundContent() {
<footer className="mt-auto pt-12">
<div className="flex flex-col gap-6">
<div className="flex gap-4">
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
<Link href="/privacy" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
{t.common.privacy}
</a>
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
</Link>
<Link href="/terms" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
{t.common.terms}
</a>
</Link>
</div>
<p className="text-[10px] leading-relaxed text-outline/60 max-w-sm">{a.disclaimer}</p>
</div>

View File

@ -6,9 +6,13 @@ import { FormEvent, useState } from "react";
import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context";
const recoveryFieldWrapperClass =
"group flex items-center rounded-2xl border border-outline-variant/50 bg-white px-5 py-4 shadow-[0_14px_30px_rgba(95,9,13,0.06)] transition-all duration-200 focus-within:border-primary focus-within:shadow-[0_18px_36px_rgba(183,19,26,0.12)]";
export default function ForgotPasswordPage() {
const { t } = useLanguage();
const f = t.auth.forgotPassword;
const supportEmail = "admin@inatrading.co.id";
const [contact, setContact] = useState("");
const [submitted, setSubmitted] = useState(false);
@ -86,23 +90,30 @@ export default function ForgotPasswordPage() {
>
{f.emailOrPhone}
</label>
<div className="flex items-center rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-4 transition-all group-focus-within:border-primary group-focus-within:bg-surface-container-lowest">
<span className="material-symbols-outlined text-outline group-focus-within:text-primary mr-3">
alternate_email
</span>
<input
id="recovery-contact"
name="recovery-contact"
type="text"
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="name@company.com"
required
className="w-full bg-transparent border-none p-0 text-lg font-medium text-on-surface placeholder:text-outline/50 focus:ring-0"
/>
<div className={recoveryFieldWrapperClass}>
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/8 text-primary transition-colors group-focus-within:bg-primary/12">
<span className="material-symbols-outlined text-[28px]">
alternate_email
</span>
</div>
<div className="min-w-0 flex-1">
<p className="mb-1 text-[10px] font-black uppercase tracking-[0.22em] text-outline/80">
Recovery Contact
</p>
<input
id="recovery-contact"
name="recovery-contact"
type="text"
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="name@company.com"
required
className="w-full border-none bg-transparent p-0 text-xl font-semibold tracking-tight text-on-surface placeholder:text-outline/45 focus:outline-none focus:ring-0"
/>
</div>
</div>
<p className="mt-3 text-sm text-on-surface-variant/70 italic">
{/* privacy note kept short */}
<p className="mt-3 text-sm text-on-surface-variant/70">
Gunakan email bisnis atau nomor HP yang terdaftar di akun Anda.
</p>
</div>
@ -132,7 +143,12 @@ export default function ForgotPasswordPage() {
<span className="material-symbols-outlined text-tertiary">contact_support</span>
<p className="text-sm text-on-surface-variant">
{f.havingTrouble}{" "}
<a href="#" className="text-tertiary font-bold hover:underline">{f.supportLink}</a>
<a
href={`mailto:${supportEmail}?subject=${encodeURIComponent("Bantuan lupa password Ina Trading")}`}
className="text-tertiary font-bold hover:underline"
>
{f.supportLink}
</a>
</p>
</div>
</footer>
@ -143,9 +159,13 @@ export default function ForgotPasswordPage() {
</main>
<div className="fixed bottom-8 right-8 z-50">
<button className="bg-surface-container-lowest text-secondary p-4 rounded-full magazine-shadow border border-outline-variant/20 hover:bg-secondary hover:text-on-secondary transition-all active:scale-95 flex items-center justify-center">
<Link
href="/help"
aria-label="Open help center"
className="bg-surface-container-lowest text-secondary p-4 rounded-full magazine-shadow border border-outline-variant/20 hover:bg-secondary hover:text-on-secondary transition-all active:scale-95 flex items-center justify-center"
>
<span className="material-symbols-outlined">help_center</span>
</button>
</Link>
</div>
</>
);

View File

@ -2,13 +2,14 @@
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context";
const authFieldWrapperClass =
"relative rounded-xl border border-outline-variant/60 bg-surface-container-high px-0 transition-all duration-300 focus-within:border-primary focus-within:bg-surface-container-lowest";
const rememberedCredentialsKey = "rememberedLoginCredentials";
export default function LoginPage() {
const router = useRouter();
@ -22,6 +23,29 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const rawRememberedCredentials = localStorage.getItem(rememberedCredentialsKey);
if (!rawRememberedCredentials) {
return;
}
try {
const rememberedCredentials = JSON.parse(rawRememberedCredentials) as {
email?: string;
password?: string;
};
if (rememberedCredentials.email && rememberedCredentials.password) {
setEmail(rememberedCredentials.email);
setPassword(rememberedCredentials.password);
setRemember(true);
}
} catch {
localStorage.removeItem(rememberedCredentialsKey);
}
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
@ -46,9 +70,14 @@ export default function LoginPage() {
}
if (remember) {
localStorage.setItem(
rememberedCredentialsKey,
JSON.stringify({ email, password })
);
localStorage.setItem("token", data.token);
localStorage.setItem("role", data.role);
} else {
localStorage.removeItem(rememberedCredentialsKey);
sessionStorage.setItem("token", data.token);
sessionStorage.setItem("role", data.role);
}
@ -59,6 +88,26 @@ export default function LoginPage() {
}
if (data.role === "seller") {
try {
const profileRes = await fetch("/api/seller/profile", {
headers: { "x-auth-token": data.token },
});
const profileData = await profileRes.json();
const profile = profileData?.data || profileData;
const isIncomplete =
!profile?.storeName ||
!profile?.biography ||
!profile?.sellerImageUrl;
if (isIncomplete || data.onboardingRequired) {
router.push("/onboarding/business");
return;
}
} catch {
// Fall back to dashboard if the profile check fails.
}
router.push("/dashboard");
return;
}
@ -175,11 +224,18 @@ export default function LoginPage() {
type="checkbox"
id="remember"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
onChange={(e) => {
const nextRemember = e.target.checked;
setRemember(nextRemember);
if (!nextRemember) {
localStorage.removeItem(rememberedCredentialsKey);
}
}}
className="w-5 h-5 rounded border-outline-variant text-primary focus:ring-primary"
/>
<label htmlFor="remember" className="text-sm font-medium text-on-surface-variant">
{l.rememberDevice}
{l.rememberMe}
</label>
</div>

View File

@ -125,7 +125,7 @@ function VerifyContent() {
sessionStorage.removeItem("otpVerified");
sessionStorage.removeItem("otpVerifiedEmail");
setSuccess(v.successSeller);
setTimeout(() => { router.push("/dashboard"); }, 1000);
setTimeout(() => { router.push("/onboarding/business"); }, 1000);
return;
}

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

View File

@ -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 (
<div className="min-h-screen bg-surface text-on-surface font-body antialiased">

View File

@ -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<PlanId, string> = {
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<PlanId, [boolean, boolean, boolean]> = {
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<PlanId>(() => {
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<string, unknown>;
};
const storeDraft = JSON.parse(storeDraftRaw) as {
payload?: {
store: Record<string, unknown>;
warehouse: Record<string, unknown>;
};
};
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 (
<>
<div className="min-h-screen bg-surface px-6 pb-36 pt-24 text-on-surface">
<div className="mx-auto flex w-full max-w-7xl gap-8">
<div className="flex-1">
<header className="mb-12">
{error ? (
<div className="mb-6 rounded-xl bg-error-container px-4 py-3 text-sm font-medium text-on-error-container">
{error}
</div>
) : null}
<div className="mb-2 flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary">
{p.step}
</span>
</div>
<h1 className="mb-4 max-w-4xl font-headline text-5xl font-extrabold leading-tight tracking-tight text-on-surface">
{p.title}
</h1>
<p className="max-w-2xl text-xl leading-relaxed text-on-surface-variant">
{p.subtitle}
</p>
</header>
<section className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
{PLANS.map((plan) => {
const isSelected = selectedPlan === plan.id;
const included = PLAN_INCLUDED[plan.id];
return (
<article
key={plan.id}
className={`relative flex min-h-[430px] flex-col rounded-xl border-l-4 bg-surface-container-lowest p-8 transition-all duration-200 hover:-translate-y-1 ${plan.borderClass} ${
isSelected ? "scale-[1.01]" : ""
}`}
>
{plan.badge ? (
<div className="absolute -top-4 left-6 rounded-full bg-primary px-3 py-1 text-[10px] font-black uppercase tracking-tight text-white shadow-lg">
{p.mostPopular}
</div>
) : null}
<h3
className={`mb-1 text-lg font-bold ${
plan.id === "professional"
? "text-primary"
: plan.id === "organization"
? "text-tertiary"
: "text-on-surface"
}`}
>
{PLAN_NAMES[plan.id]}
</h3>
<div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-black text-on-surface">{plan.price}</span>
<span className="text-sm text-on-surface-variant">{plan.unit}</span>
</div>
<div className="mb-8 space-y-4">
<p className="flex items-center gap-2 text-sm font-semibold text-on-surface">
<span className="material-symbols-outlined text-sm">group</span>
{plan.seats}
</p>
<div className="space-y-3">
{plan.featureKeys.map((key, idx) => {
const featureLabel = p.features[key as keyof typeof p.features];
const isIncluded = included[idx];
return (
<div
key={key}
className={`flex gap-2 text-sm ${
isIncluded ? "text-on-surface-variant" : "text-on-surface-variant opacity-40"
}`}
>
<span
className={`material-symbols-outlined text-sm ${
isIncluded ? featureIconClass(plan.id) : "text-on-surface-variant"
}`}
>
{isIncluded ? "check" : "close"}
</span>
<span>{featureLabel}</span>
</div>
);
})}
</div>
</div>
<button
type="button"
onClick={() => persistPlan(plan.id)}
className={`mt-auto w-full rounded-xl px-4 py-4 font-bold transition-all active:scale-95 ${plan.actionClass}`}
>
{isSelected ? p.selected : p[plan.ctaKey]}
</button>
</article>
);
})}
</section>
</div>
</div>
</div>
<footer className="fixed bottom-0 left-0 right-0 z-40 bg-surface-container-lowest px-6 py-6 shadow-[0_-10px_30px_rgba(25,28,29,0.04)]">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<button
type="button"
onClick={() => router.push("/onboarding/store-detail")}
className="flex items-center gap-2 rounded-xl px-6 py-2 font-bold text-on-surface transition-colors hover:bg-surface-container"
>
<span className="material-symbols-outlined">arrow_back</span>
{p.back}
</button>
<div className="flex gap-4">
<button
type="button"
onClick={handleContinue}
disabled={submitting}
className="px-4 font-medium text-on-surface-variant disabled:opacity-70"
>
{p.skip}
</button>
<button
type="button"
onClick={handleContinue}
disabled={submitting}
className="rounded-xl bg-primary px-10 py-4 font-bold text-on-primary shadow-xl shadow-primary/20 transition-all hover:scale-105 active:scale-95 disabled:opacity-70 disabled:hover:scale-100"
>
{submitting ? `${p.submitSelection}...` : p.submitSelection}
</button>
</div>
</div>
</footer>
</>
);
return null;
}

View File

@ -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<string, unknown>;
};
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() {
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
{sd.warehouseType}
</label>
<select
value={warehouse.warehouseType}
onChange={(e) =>
setWarehouse((prev) => ({
...prev,
warehouseType: e.target.value as WarehouseType,
}))
}
className={fieldClass}
>
<option value="INA">INA</option>
<option value="OTHER">{sd.other}</option>
</select>
</div>
<div>
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
{sd.country}

View File

@ -40,9 +40,8 @@ export default function SuccessPage() {
<p className="text-on-surface-variant leading-relaxed mb-8 max-w-sm">
Profil UMKM institusional Anda telah tercatat dan sedang ditinjau
oleh tim verifikasi kami. Setelah disetujui, Anda akan mendapat akses
penuh ke protokol{" "}
<span className="text-tertiary font-semibold">Trading Desk Alpha</span>.
Lanjutkan ke dashboard untuk memantau status Anda.
penuh ke Dashboard seller Ina Tarading. Lanjutkan ke dashboard
untuk memantau status Anda.
</p>
<Link

View File

@ -3,6 +3,7 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
@ -459,85 +460,21 @@ export default function AdminProductDetailPage() {
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="05" title="Models and Pricing" />
{models.length ? (
<div className="space-y-4">
{models.map((model, index) => (
<div key={`${model.sku || model.name || index}`} className="rounded-2xl border border-surface-container p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-black text-on-surface">{model.name || `Model ${index + 1}`}</p>
<p className="mt-1 text-xs text-outline">SKU: {model.sku || "-"}</p>
</div>
</div>
<div className="space-y-4">
{(Array.isArray(model.productMeasurements) ? model.productMeasurements : []).map((measurement, measurementIndex) => (
<div key={`${measurement.measurementType || measurementIndex}`} className="rounded-xl bg-surface-container-low p-4">
<Row
label="Measurement"
value={[
measurement.measurementType,
measurement.measurementValue,
]
.filter(Boolean)
.join(" - ") || "—"}
/>
<Row
label="Price"
value={
measurement.price != null
? `${measurement.currency || "IDR"} ${Number(measurement.price).toLocaleString("id-ID")}`
: "—"
}
/>
<Row
label="Weight"
value={
measurement.weight != null
? `${measurement.weight} ${measurement.weightType || ""}`
: undefined
}
/>
<Row
label="Dimension"
value={
[measurement.length, measurement.width, measurement.height].some(Boolean)
? `${[measurement.length, measurement.width, measurement.height].filter(Boolean).join(" x ")} ${measurement.dimensionType || ""}`
: undefined
}
/>
{measurement.isConfigurePromotionPrice ? (
<Row
label="Promotion Price"
value={
measurement.promotionPrice != null
? `${measurement.promotionCurrency || measurement.currency || "IDR"} ${Number(measurement.promotionPrice).toLocaleString("id-ID")}`
: undefined
}
/>
) : null}
{Array.isArray(measurement.warehouses) && measurement.warehouses.length ? (
<div className="mt-3 border-t border-surface-container pt-3">
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Warehouses</p>
{measurement.warehouses.map((warehouse, warehouseIndex) => (
<div key={`${warehouse.id || warehouseIndex}`} className="flex justify-between py-1 text-sm">
<span className="text-on-surface-variant">
{[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || "-"}
</span>
<span className="font-bold text-on-surface">{warehouse.stock ?? 0} unit</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl bg-surface-container-low p-4 text-sm font-semibold text-outline">Tidak ada model produk</div>
)}
<ProductVariantShowcase
product={product}
warehouseLabelResolver={(warehouse) =>
[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
}
labels={{
summaryTitle: "Variant Summary",
structureTitle: "Variant Structure",
productPrice: "Product Price",
productWeight: "Product Weight",
productDimension: "Product Dimension",
selectedDetailTitle: "Selected Variant Detail",
stock: "Warehouses",
}}
/>
</div>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">

View File

@ -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 (
<div className={`rounded-lg border p-4 ${changed ? "border-amber-300 bg-amber-50/70" : accent ? "border-primary/20 bg-white" : "border-slate-100 bg-slate-50"}`}>
<div className={`rounded-2xl border p-5 ${changed ? "border-amber-300 bg-amber-50/70" : "border-surface-container bg-surface-container-low"}`}>
<div className="flex items-center gap-3 mb-3">
{imgUrl(model.imageId) && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgUrl(model.imageId)!} alt={model.name} className="w-12 h-12 object-cover rounded-lg border border-slate-100" />
<img src={imgUrl(model.imageId)!} alt={model.name} className="h-12 w-12 object-cover rounded-xl border border-surface-container" />
)}
<div>
<p className="font-black text-sm text-on-surface">{model.name || `Model ${index + 1}`}</p>
{changed && (
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-amber-700">
Updated
Diperbarui
</p>
)}
{hasMeasurements && (
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-primary">
{measurements.length} measurement variation(s)
{measurements.length} variasi ukuran
</p>
)}
</div>
</div>
<Row label="SKU" value={model.sku} />
<Row label="Harga" value={hasMeasurements ? "Menggunakan harga measurement" : modelPrice} />
<Row label="Berat" value={hasMeasurements ? "Menggunakan berat measurement" : modelWeight} />
<Row label="Dimensi" value={hasMeasurements ? "Menggunakan dimensi measurement" : modelDimension} />
{model.isConfigurePromotionPrice && !hasMeasurements && (
<Row label="Harga Promo" value={modelPromotionPrice} />
)}
{!hasMeasurements ? (
<>
<Row label="Harga" value={modelPrice} />
<Row label="Berat" value={modelWeight} />
<Row label="Dimensi" value={modelDimension} />
{model.isConfigurePromotionPrice && (
<Row label="Harga Promo" value={modelPromotionPrice} />
)}
</>
) : null}
{!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
<div className="mt-3 pt-3 border-t border-surface-container">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">Warehouse & Stok</p>
{model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1">
<span className="text-slate-500">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
<span className="text-on-surface-variant">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
<span className="font-black">{warehouse.stock ?? 0} unit</span>
</div>
))}
@ -202,7 +214,7 @@ function ModelCard({
)}
{hasMeasurements && (
<div className="mt-4 space-y-3 border-t border-slate-100 pt-4">
<div className="mt-4 space-y-3 border-t border-surface-container pt-4">
{measurements.map((measurement: ReviewMeasurement, measurementIndex: number) => {
const measurementPrice =
formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-";
@ -221,7 +233,7 @@ function ModelCard({
return (
<div
key={`${measurement.measurementType || "measurement"}-${measurement.measurementValue || measurementIndex}`}
className={`rounded-lg border p-4 ${accent ? "border-primary/10 bg-primary/5" : "border-slate-200 bg-white/80"}`}
className="rounded-xl border border-surface-container bg-white p-4"
>
<p className="mb-3 text-xs font-black uppercase tracking-wider text-on-surface">
{formatMeasurementLabel(measurement, measurementIndex)}
@ -233,11 +245,11 @@ function ModelCard({
<Row label="Harga Promo" value={measurementPromotionPrice} />
)}
{Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
<div className="mt-3 pt-3 border-t border-surface-container">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">Warehouse & Stok</p>
{measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1">
<span className="text-slate-500">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
<span className="text-on-surface-variant">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
<span className="font-black">{warehouse.stock ?? 0} unit</span>
</div>
))}
@ -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 (
<div className="flex justify-between gap-4 py-2.5 border-b border-slate-100 last:border-0 text-sm">
<span className="text-slate-500 font-medium flex-shrink-0">{label}</span>
<div className="flex justify-between gap-4 py-2 border-b border-surface-container last:border-0 text-sm">
<span className="text-on-surface-variant font-medium flex-shrink-0">{label}</span>
<span className="font-semibold text-on-surface text-right">{display}</span>
</div>
);
}
function ToggleBadge({ label, value }: { label: string; value: boolean }) {
return (
<div className="flex items-center justify-between rounded-xl bg-surface-container-low p-4">
<span className="text-sm font-bold text-on-surface">{label}</span>
<span
className={`rounded-full px-2.5 py-1 text-[10px] font-black uppercase tracking-wider ${
value ? "bg-primary/10 text-primary" : "bg-surface-container text-outline"
}`}
>
{value ? "Ya" : "Tidak"}
</span>
</div>
);
}
function SectionHeader({ step, title }: { step: string; title: string }) {
return (
<div className="mb-6 flex items-center gap-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-xs font-black text-white shadow-md shadow-primary/20">
{step}
</div>
<h2 className="font-headline text-xl font-black tracking-tight text-on-surface">{title}</h2>
</div>
);
}
function SectionCard({
title,
accent,
@ -277,14 +315,14 @@ function SectionCard({
children: React.ReactNode;
}) {
return (
<div className={`rounded-xl border shadow-sm p-5 ${changed ? "border-amber-300 bg-amber-50/70" : accent ? "border-primary/30 bg-primary/5" : "bg-white border-slate-100"}`}>
<div className={`mb-4 flex items-center justify-between gap-3 pb-3 border-b ${changed ? "border-amber-200" : accent ? "border-primary/20" : "border-slate-100"}`}>
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : accent ? "text-primary" : "text-slate-400"}`}>
<div className={`rounded-2xl border shadow-sm p-6 ${changed ? "border-amber-300 bg-amber-50/70" : "bg-surface-container-lowest border-outline-variant/10"}`}>
<div className={`mb-4 flex items-center justify-between gap-3 pb-3 border-b ${changed ? "border-amber-200" : "border-surface-container"}`}>
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : "text-outline"}`}>
{title}
</h3>
{changed && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-amber-800">
Updated
Diperbarui
</span>
)}
</div>
@ -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 (
<div className="flex-1 min-w-0 space-y-4">
{/* Column label */}
<div className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-center ${accent ? "bg-primary text-white" : "bg-slate-100 text-slate-500"}`}>
<div className={`px-5 py-4 rounded-2xl text-[11px] font-black uppercase tracking-[0.18em] ${accent ? "bg-primary text-white" : "bg-surface-container-low text-on-surface-variant"}`}>
{label}
</div>
{/* Images */}
{allImages.length > 0 && (
<SectionCard title="Gambar Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}>
<div className="flex gap-2 flex-wrap">
<div className="grid grid-cols-2 gap-3">
{allImages.map((imageId: string, i: number) => {
const url = imgUrl(imageId);
if (!url) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img key={i} src={url} alt={`img-${i}`} className="w-20 h-20 object-cover rounded-lg border border-slate-100" />
<img key={i} src={url} alt={`img-${i}`} className="h-32 w-full object-cover rounded-xl border border-surface-container" />
);
})}
</div>
@ -408,33 +450,30 @@ function ProductColumn({
<Row label="Deskripsi" value={product.description} />
<Row label="Kategori" value={product.subCategory?.category?.name} />
<Row label="Sub Kategori" value={product.subCategory?.name} />
<Row label="Brand New" value={product.isNew} />
<Row label="Eligible to Export" value={product.isEligibleToExport} />
<Row label="Produk Baru" value={product.isNew} />
<Row label="Bisa Diekspor" value={product.isEligibleToExport} />
<Row label="Pre-order" value={product.isPreOrder} />
{product.isPreOrder && <Row label="Pre-order Days" value={product.preOrderDay} />}
<Row label="State" value={product.state} />
{product.isPreOrder && <Row label="Durasi Pre-order" value={product.preOrderDay} />}
<Row label="Status" value={product.state} />
</SectionCard>
{/* Features */}
{features.length > 0 && (
<SectionCard title="Fitur Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["productFeatures"])}>
<ul className="space-y-1.5">
<div className="flex flex-wrap gap-2">
{features.map((f: string, i: number) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className={`material-symbols-outlined text-base mt-0.5 ${accent ? "text-primary" : "text-slate-400"}`} style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
<span>{f}</span>
</li>
<span key={i} className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed">{f}</span>
))}
</ul>
</div>
</SectionCard>
)}
{/* Keywords */}
{keywords.length > 0 && (
<SectionCard title="Keywords" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
<SectionCard title="Kata Kunci" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
<div className="flex flex-wrap gap-2">
{keywords.map((k: string) => (
<span key={k} className={`px-3 py-1 rounded-full text-xs font-bold ${accent ? "bg-primary/10 text-primary" : "bg-slate-100 text-slate-600"}`}>{k}</span>
<span key={k} className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary">{k}</span>
))}
</div>
</SectionCard>
@ -443,26 +482,21 @@ function ProductColumn({
{/* Models */}
{models.length > 0 && (
<SectionCard title={`Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}>
<div className="space-y-3">
{models.map((model: ReviewModel, index: number) => (
<ModelCard
key={`${model.sku || model.name || index}`}
model={model}
index={index}
accent={accent}
changed={hasChangesForPaths(compareRows, ["productModels"])}
/>
))}
</div>
<ProductVariantShowcase
product={product}
warehouseLabelResolver={(warehouse) =>
[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
}
/>
</SectionCard>
)}
{/* Compliance */}
{product.complianceInformation && (
<SectionCard title="Compliance" accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
<SectionCard title="Kepatuhan" accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
<Row label="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} />
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} />
<Row label="Peringatan Keamanan" value={product.complianceInformation.safetyWarning} />
<Row label="Barang Berbahaya" value={product.complianceInformation.isDangerousGoodRegulation} />
</SectionCard>
)}
@ -614,6 +648,25 @@ function AdminReviewDetailPageInner() {
</div>
);
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}
<div className="m-6 space-y-6 pb-10">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-8 pb-16">
<div className="mb-2">
<nav className="mb-4 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline">
<button
onClick={() => router.push(backHref)}
className="transition-colors hover:text-primary"
>
{isReadonly ? "Produk" : "Review"}
</button>
<span className="material-symbols-outlined text-sm">chevron_right</span>
<span className="text-primary">
{isReadonly ? "Detail Produk" : isComparison ? "Review Update" : "Review Produk Baru"}
</span>
</nav>
<div>
<nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center">
<button onClick={() => router.push(backHref)} className="hover:text-primary transition-colors">
{isReadonly ? "Products" : "Reviews"}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-headline text-4xl font-black tracking-tighter text-on-surface">
{product.name || "Review Detail"}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-3">
<span
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest border-l-2 ${
isComparison
? "border-secondary bg-secondary-fixed text-on-secondary-fixed-variant"
: "border-amber-500 bg-amber-50 text-amber-700"
}`}
>
{isComparison ? "Review Update" : product.state || "Review Produk Baru"}
</span>
{product.seller?.name ? (
<span className="text-sm text-slate-500">
by{" "}
<span className="font-semibold text-on-surface">
{product.seller.name}
</span>
</span>
) : null}
</div>
</div>
<button
onClick={() => router.back()}
className="flex items-center gap-2 rounded-xl border border-outline-variant/20 px-4 py-2 text-sm font-bold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-base">arrow_back</span>
Kembali
</button>
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
<span className="text-primary">
{isReadonly ? "Product Detail" : isComparison ? "Review Update" : "Review Produk Baru"}
</span>
</nav>
<h1 className="text-3xl font-black tracking-tight text-on-surface">{product.name}</h1>
<div className="flex items-center gap-3 mt-2">
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest border-l-2 ${isComparison ? "bg-secondary-fixed text-on-secondary-fixed-variant border-secondary" : "bg-amber-50 text-amber-700 border-amber-500"}`}>
{isComparison ? "Update Review" : product.state}
</span>
{product.seller?.name && (
<span className="text-sm text-slate-500">by <span className="font-semibold text-on-surface">{product.seller.name}</span></span>
)}
</div>
</div>
<button onClick={() => router.back()} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors">
<span className="material-symbols-outlined text-base">arrow_back</span>
Kembali
</button>
</div>
{/* Comparison notice */}
{isComparison && (
<div className="p-4 bg-secondary-fixed/50 rounded-xl flex items-center gap-3 text-sm font-semibold text-on-secondary-fixed-variant">
<span className="material-symbols-outlined text-secondary">compare_arrows</span>
Bandingkan perubahan yang diajukan seller (kiri) dengan data produk saat ini (kanan).
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="01" title="Ringkasan Review" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Kategori Utama</p>
<p className="text-sm font-semibold text-on-surface">
{product.subCategory?.category?.name || "—"}
</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Sub Kategori</p>
<p className="text-sm font-semibold text-on-surface">
{product.subCategory?.name || "—"}
</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Mode Review</p>
<p className="text-sm font-semibold text-on-surface">
{isComparison ? "Perbandingan update" : "Produk baru"}
</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Penjual</p>
<p className="text-sm font-semibold text-on-surface">
{product.seller?.name || "-"}
</p>
</div>
</div>
)}
</div>
{/* Content — 1 column (isNew) or 2 columns (update) */}
{isComparison ? (
<div className="flex gap-6 items-start">
<ProductColumn product={product} label="Versi Terbaru (Diajukan)" accent compareRows={compareRows} />
<ProductColumn product={oldProduct} label="Versi Saat Ini (Live)" />
<div className="rounded-2xl border border-secondary/15 bg-secondary-fixed/35 p-5 text-sm font-semibold text-on-secondary-fixed-variant shadow-sm">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-secondary">compare_arrows</span>
Bandingkan perubahan yang diajukan seller dengan data produk yang saat ini live.
</div>
</div>
) : null}
{isComparison ? (
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="02" title="Versi Diajukan" />
<ProductColumn
product={product}
label="Versi Terbaru (Diajukan)"
accent
compareRows={compareRows}
/>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="03" title="Versi Live Saat Ini" />
<ProductColumn
product={oldProduct}
label="Versi Saat Ini (Live)"
/>
</div>
</div>
) : (
<div className="space-y-4">
<ProductColumn product={product} label="Produk Baru" accent />
</div>
<>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="02" title="Detail Dasar" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Kategori Utama</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Sub Kategori</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.name || "—"}</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-12">
<div className="space-y-6 rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm xl:col-span-7">
<SectionHeader step="03" title="Deskripsi" />
<div>
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Nama Produk</p>
<p className="text-base font-semibold text-on-surface">{product.name || "—"}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ToggleBadge label="Pre-order" value={!!product.isPreOrder} />
<ToggleBadge label="Produk Baru" value={product.isNew !== false} />
</div>
{product.isPreOrder ? (
<div>
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Durasi Pre-order</p>
<p className="text-sm font-semibold text-on-surface">{product.preOrderDay || "—"}</p>
</div>
) : null}
<div>
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Deskripsi</p>
<p className="whitespace-pre-line text-sm leading-7 text-on-surface-variant">{product.description || "—"}</p>
</div>
{features.length ? (
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Fitur Produk</p>
<div className="flex flex-wrap gap-2">
{features.map((feature) => (
<span
key={feature}
className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed"
>
{feature}
</span>
))}
</div>
</div>
) : null}
{keywords.length ? (
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Kata Kunci</p>
<div className="flex flex-wrap gap-2">
{keywords.map((keyword) => (
<span
key={keyword}
className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary"
>
{keyword}
</span>
))}
</div>
</div>
) : null}
</div>
<div className="space-y-6 xl:col-span-5">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="04" title="Galeri" />
{allImages.length ? (
<div className="grid grid-cols-2 gap-3">
{allImages.map((imageId, index) => {
const src = imgUrl(imageId);
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`${imageId}-${index}`}
src={src}
alt={`${product.name || "product"}-${index + 1}`}
className="h-32 w-full rounded-xl border border-surface-container object-cover"
/>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-xl bg-surface-container-low text-sm font-semibold text-outline">
Tidak ada gambar
</div>
)}
</div>
{product.seller ? (
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="05" title="Penjual" />
<div className="flex items-center gap-4">
{imgUrl(product.seller.imageId) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imgUrl(product.seller.imageId) || ""}
alt={product.seller.name || "seller"}
className="h-14 w-14 rounded-full border border-surface-container object-cover"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<span className="material-symbols-outlined">storefront</span>
</div>
)}
<div>
<p className="font-black text-on-surface">{product.seller.name || "-"}</p>
<p className="mt-0.5 text-xs text-outline">ID: {product.seller.id || "-"}</p>
</div>
</div>
</div>
) : null}
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="06" title="Model dan Harga" />
<ProductVariantShowcase
product={product}
warehouseLabelResolver={(warehouse) =>
[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
}
/>
</div>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="07" title="Informasi Tambahan" />
{productInfos.length || categoryInfos.length ? (
<div className="space-y-6">
{productInfos.length ? (
<div>
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">Informasi Produk</p>
<div className="space-y-1">
{productInfos.map((item) => (
<Row key={`${item.paramName}-${item.paramValue}`} label={item.paramName} value={item.paramValue} />
))}
</div>
</div>
) : null}
{categoryInfos.length ? (
<div>
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">Informasi Kategori</p>
<div className="space-y-1">
{categoryInfos.map((item) => (
<Row key={`${item.paramName}-${item.paramValue}`} label={item.paramName} value={item.paramValue} />
))}
</div>
</div>
) : null}
</div>
) : (
<div className="rounded-xl bg-surface-container-low p-4 text-sm font-semibold text-outline">
Tidak ada informasi tambahan
</div>
)}
</div>
<div className="space-y-8">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="08" title="Kepatuhan" />
<div className="space-y-1">
<Row label="Negara Asal" value={product.complianceInformation?.countryOfOrigin} />
<Row label="Peringatan Keamanan" value={product.complianceInformation?.safetyWarning} />
<Row label="Barang Berbahaya" value={product.complianceInformation?.isDangerousGoodRegulation} />
<Row label="Bisa Diekspor" value={product.isEligibleToExport} />
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="09" title="Garansi" />
<div className="space-y-1">
<Row label="Tipe" value={product.warrantyInformation?.type} />
<Row
label="Durasi"
value={
product.warrantyInformation?.duration
? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType || ""}`
: undefined
}
/>
</div>
</div>
</div>
</div>
</>
)}
{/* Seller card (single, below both columns) */}
{product.seller && (
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-5">
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-slate-400 mb-4 pb-3 border-b border-slate-100">Seller</h3>
{product.seller && isComparison ? (
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step={isComparison ? "04" : "03"} title="Penjual" />
<div className="flex items-center gap-4">
{imgUrl(product.seller.imageId) ? (
// eslint-disable-next-line @next/next/no-img-element
@ -728,11 +1042,11 @@ function AdminReviewDetailPageInner() {
</div>
</div>
</div>
)}
) : null}
{/* Action bar */}
{!isReadonly ? (
<div className="flex flex-col items-end gap-2 pt-2">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step={isComparison ? "05" : "10"} title="Tindakan Review" />
{actionSuccess && (
<div className="w-full p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
<span className="material-symbols-outlined text-tertiary">check_circle</span>
@ -745,32 +1059,36 @@ function AdminReviewDetailPageInner() {
{actionError}
</div>
)}
{acting && (
<p className="text-xs text-slate-400 flex items-center gap-1.5">
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
Memproses review...
</p>
)}
{!actionSuccess && (
<div className="flex items-center gap-4">
<button
onClick={() => { 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"
>
<span className="material-symbols-outlined text-base">block</span>
{isComparison ? "Reject Update" : "Reject Product"}
</button>
<button
onClick={() => 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"
>
<span className="material-symbols-outlined text-base">check_circle</span>
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
</button>
</div>
)}
<div className="flex flex-col items-end gap-3">
{acting ? (
<p className="text-xs text-slate-400 flex items-center gap-1.5">
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
Memproses review...
</p>
) : null}
{!actionSuccess ? (
<div className="flex items-center gap-4">
<button
onClick={() => { 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"
>
<span className="material-symbols-outlined text-base">block</span>
{isComparison ? "Tolak Update" : "Tolak Produk"}
</button>
<button
onClick={() => 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"
>
<span className="material-symbols-outlined text-base">check_circle</span>
{acting ? "Memproses..." : isComparison ? "Setujui Update" : "Setujui Produk"}
</button>
</div>
) : null}
</div>
</div>
) : null}
</div>
@ -780,7 +1098,7 @@ function AdminReviewDetailPageInner() {
export default function AdminReviewDetailPage() {
return (
<Suspense fallback={<div className="m-6 py-24 text-sm font-semibold text-slate-400">Loading review detail...</div>}>
<Suspense fallback={<div className="m-6 py-24 text-sm font-semibold text-slate-400">Memuat detail review...</div>}>
<AdminReviewDetailPageInner />
</Suspense>
);

View File

@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
const endpointMap: Record<string, string> = {
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",

212
src/app/help/page.tsx Normal file
View File

@ -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 (
<main className="min-h-screen bg-surface text-on-surface">
<section className="relative overflow-hidden bg-[linear-gradient(145deg,#f8f2f0_0%,#ffffff_48%,#f6eeeb_100%)]">
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -top-24 right-0 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 h-64 w-64 rounded-full bg-tertiary/10 blur-3xl" />
</div>
<div className="relative mx-auto max-w-6xl px-8 py-10 md:px-12 lg:px-16">
<div className="flex items-center justify-between gap-6">
<Link href="/" className="inline-flex items-center">
<Image src="/ina_logo.png" alt="Ina Trading" width={170} height={52} priority />
</Link>
<LanguageToggle />
</div>
<div className="mt-20 grid gap-10 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
<div>
<p className="mb-4 text-[11px] font-black uppercase tracking-[0.28em] text-primary">
Public Help Center
</p>
<h1 className="max-w-3xl text-5xl font-black tracking-tight text-on-surface md:text-6xl">
Bantuan cepat tanpa perlu login.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-on-surface-variant">
Gunakan halaman ini untuk bantuan akses akun, onboarding seller,
dan pertanyaan umum seputar penggunaan Ina Trading.
</p>
</div>
<div className="rounded-3xl border border-primary/10 bg-white/85 p-8 shadow-[0_24px_80px_rgba(122,33,18,0.08)] backdrop-blur">
<div className="mb-6 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<span className="material-symbols-outlined">support_agent</span>
</div>
<h2 className="text-2xl font-black tracking-tight text-on-surface">
Butuh bantuan langsung?
</h2>
<p className="mt-3 text-sm leading-relaxed text-on-surface-variant">
Untuk kendala akun atau onboarding, kirim email ke admin kami
dan sertakan email akun, nomor HP, serta screenshot jika ada.
</p>
<a
href={`mailto:${supportEmail}?subject=${encodeURIComponent("Bantuan Ina Trading")}`}
className="mt-6 inline-flex w-full items-center justify-center gap-3 rounded-2xl bg-primary px-6 py-4 text-center text-sm font-black uppercase tracking-[0.18em] text-white transition-all hover:brightness-110"
>
<span className="material-symbols-outlined text-base">mail</span>
Email Admin
</a>
<p className="mt-4 break-all text-xs font-semibold text-outline">
{supportEmail}
</p>
</div>
</div>
</div>
</section>
<section className="mx-auto max-w-6xl px-8 py-14 md:px-12 lg:px-16">
<div className="mb-8 flex items-end gap-6">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-primary">
Fokus Bantuan
</p>
<h2 className="mt-2 text-3xl font-black tracking-tight">
Area yang paling sering ditanyakan
</h2>
</div>
<div className="hidden h-px flex-1 bg-outline-variant/30 md:block" />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{categories.map((item) => (
<article
key={item.title}
className="rounded-2xl border border-outline-variant/20 bg-white p-6 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
>
<span className="material-symbols-outlined text-4xl text-primary">
{item.icon}
</span>
<h3 className="mt-5 text-lg font-black tracking-tight text-on-surface">
{item.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-on-surface-variant">
{item.description}
</p>
</article>
))}
</div>
</section>
<section className="mx-auto max-w-6xl px-8 pb-16 md:px-12 lg:px-16">
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-3xl border border-outline-variant/20 bg-surface-container-lowest p-8 shadow-sm">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-primary">
Quick Answers
</p>
<h2 className="mt-2 text-3xl font-black tracking-tight">
Pertanyaan umum
</h2>
<div className="mt-8 space-y-4">
{quickAnswers.map((item) => (
<article
key={item.question}
className="rounded-2xl border border-outline-variant/20 bg-white p-5"
>
<h3 className="text-base font-black text-on-surface">
{item.question}
</h3>
<p className="mt-2 text-sm leading-relaxed text-on-surface-variant">
{item.answer}
</p>
</article>
))}
</div>
</div>
<div className="rounded-3xl bg-[linear-gradient(160deg,#b7131a_0%,#931117_55%,#5f090d_100%)] p-8 text-white shadow-[0_30px_80px_rgba(122,33,18,0.18)]">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-white/70">
Contact
</p>
<h2 className="mt-2 text-3xl font-black tracking-tight">
Hubungi tim admin
</h2>
<p className="mt-4 text-sm leading-relaxed text-white/80">
Jika bantuan mandiri belum cukup, kirim email dengan detail
kendala Anda. Default email app di perangkat Anda akan terbuka.
</p>
<div className="mt-8 rounded-2xl border border-white/15 bg-white/10 p-5">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/60">
Email tujuan
</p>
<p className="mt-2 text-lg font-black tracking-tight">
{supportEmail}
</p>
</div>
<div className="mt-8 flex flex-col gap-3">
<a
href={`mailto:${supportEmail}?subject=${encodeURIComponent("Bantuan akun Ina Trading")}`}
className="inline-flex items-center justify-center gap-3 rounded-2xl bg-white px-6 py-4 text-sm font-black uppercase tracking-[0.18em] text-primary transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-base">outgoing_mail</span>
Buka Email App
</a>
<Link
href="/login"
className="inline-flex items-center justify-center gap-3 rounded-2xl border border-white/30 px-6 py-4 text-sm font-black uppercase tracking-[0.18em] text-white transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-base">arrow_back</span>
Kembali ke Login
</Link>
</div>
</div>
</div>
</section>
</main>
);
}

109
src/app/privacy/page.tsx Normal file
View File

@ -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 (
<main className="min-h-screen bg-surface text-on-surface">
<section className="border-b border-outline-variant/20 bg-[linear-gradient(160deg,#f8f2f0_0%,#ffffff_52%,#f5ece8_100%)]">
<div className="mx-auto max-w-5xl px-8 py-10 md:px-12">
<div className="flex items-center justify-between gap-6">
<Link href="/" className="inline-flex items-center">
<Image src="/ina_logo.png" alt="Ina Trading" width={170} height={52} priority />
</Link>
<div className="flex items-center gap-3">
<LanguageToggle />
<Link
href="/help"
className="hidden items-center gap-2 text-sm font-bold text-secondary transition-colors hover:text-primary sm:inline-flex"
>
<span className="material-symbols-outlined text-lg">help_outline</span>
Bantuan
</Link>
</div>
</div>
<div className="mt-16 max-w-3xl">
<p className="text-[11px] font-black uppercase tracking-[0.28em] text-primary">
Public Policy
</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-on-surface md:text-6xl">
Kebijakan Privasi
</h1>
<p className="mt-6 text-lg leading-relaxed text-on-surface-variant">
Halaman ini menjelaskan secara ringkas bagaimana Ina Trading mengelola
dan melindungi data pengguna tanpa memerlukan login terlebih dahulu.
</p>
</div>
</div>
</section>
<section className="mx-auto max-w-5xl px-8 py-14 md:px-12">
<div className="grid gap-6">
{sections.map((section) => (
<article
key={section.title}
className="rounded-3xl border border-outline-variant/20 bg-white p-8 shadow-sm"
>
<h2 className="text-2xl font-black tracking-tight text-on-surface">
{section.title}
</h2>
<p className="mt-4 text-base leading-relaxed text-on-surface-variant">
{section.body}
</p>
</article>
))}
</div>
<div className="mt-10 rounded-3xl bg-surface-container-low p-8">
<p className="text-sm leading-relaxed text-on-surface-variant">
Jika Anda membutuhkan klarifikasi lebih lanjut terkait privasi data,
silakan kunjungi halaman bantuan publik atau hubungi tim admin melalui
email resmi Ina Trading.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/help"
className="inline-flex items-center gap-2 rounded-2xl bg-primary px-5 py-3 text-sm font-black uppercase tracking-[0.18em] text-white transition-colors hover:brightness-110"
>
<span className="material-symbols-outlined text-base">support_agent</span>
Buka Bantuan
</Link>
<Link
href="/terms"
className="inline-flex items-center gap-2 rounded-2xl border border-outline-variant/30 px-5 py-3 text-sm font-black uppercase tracking-[0.18em] text-on-surface transition-colors hover:bg-surface-container"
>
<span className="material-symbols-outlined text-base">gavel</span>
Lihat Syarat
</Link>
</div>
</div>
</section>
</main>
);
}

109
src/app/terms/page.tsx Normal file
View File

@ -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 (
<main className="min-h-screen bg-surface text-on-surface">
<section className="border-b border-outline-variant/20 bg-[linear-gradient(160deg,#f8f2f0_0%,#ffffff_52%,#f5ece8_100%)]">
<div className="mx-auto max-w-5xl px-8 py-10 md:px-12">
<div className="flex items-center justify-between gap-6">
<Link href="/" className="inline-flex items-center">
<Image src="/ina_logo.png" alt="Ina Trading" width={170} height={52} priority />
</Link>
<div className="flex items-center gap-3">
<LanguageToggle />
<Link
href="/help"
className="hidden items-center gap-2 text-sm font-bold text-secondary transition-colors hover:text-primary sm:inline-flex"
>
<span className="material-symbols-outlined text-lg">help_outline</span>
Bantuan
</Link>
</div>
</div>
<div className="mt-16 max-w-3xl">
<p className="text-[11px] font-black uppercase tracking-[0.28em] text-primary">
Public Policy
</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-on-surface md:text-6xl">
Syarat & Ketentuan
</h1>
<p className="mt-6 text-lg leading-relaxed text-on-surface-variant">
Ringkasan ini disediakan untuk akses publik tanpa login agar
pengguna dapat memahami kerangka penggunaan platform Ina Trading.
</p>
</div>
</div>
</section>
<section className="mx-auto max-w-5xl px-8 py-14 md:px-12">
<div className="grid gap-6">
{sections.map((section) => (
<article
key={section.title}
className="rounded-3xl border border-outline-variant/20 bg-white p-8 shadow-sm"
>
<h2 className="text-2xl font-black tracking-tight text-on-surface">
{section.title}
</h2>
<p className="mt-4 text-base leading-relaxed text-on-surface-variant">
{section.body}
</p>
</article>
))}
</div>
<div className="mt-10 rounded-3xl bg-surface-container-low p-8">
<p className="text-sm leading-relaxed text-on-surface-variant">
Dengan melanjutkan penggunaan platform, Anda dianggap memahami dan
menyetujui kerangka syarat dasar ini. Untuk bantuan lebih lanjut,
gunakan halaman bantuan publik.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/help"
className="inline-flex items-center gap-2 rounded-2xl bg-primary px-5 py-3 text-sm font-black uppercase tracking-[0.18em] text-white transition-colors hover:brightness-110"
>
<span className="material-symbols-outlined text-base">support_agent</span>
Buka Bantuan
</Link>
<Link
href="/privacy"
className="inline-flex items-center gap-2 rounded-2xl border border-outline-variant/30 px-5 py-3 text-sm font-black uppercase tracking-[0.18em] text-on-surface transition-colors hover:bg-surface-container"
>
<span className="material-symbols-outlined text-base">shield_lock</span>
Lihat Privasi
</Link>
</div>
</div>
</section>
</main>
);
}

View File

@ -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 (
<div className="flex justify-between gap-4 border-b border-surface-container py-2 text-sm last:border-0">
<span className="font-medium text-on-surface-variant">{label}</span>
<span className="text-right font-semibold text-on-surface">{String(value)}</span>
</div>
);
}
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<Labels>;
}) {
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 (
<div className="rounded-2xl bg-surface-container-low p-4 text-sm font-semibold text-outline">
{l.noModels}
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5 shadow-sm xl:col-span-4">
<p className="mb-4 text-[10px] font-black uppercase tracking-[0.18em] text-outline">{l.summaryTitle}</p>
<div className="space-y-3">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{l.productPrice}</p>
<p className="mt-2 text-lg font-black text-on-surface">{getEffectivePriceLabel(models) || "-"}</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{l.productWeight}</p>
<p className="mt-2 text-sm font-semibold text-on-surface">{getEffectiveWeightLabel(models) || l.varied}</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{l.productDimension}</p>
<p className="mt-2 text-sm font-semibold text-on-surface">{getEffectiveDimensionLabel(models) || l.varied}</p>
</div>
<div className="rounded-xl border border-primary/10 bg-primary/5 p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-primary">{l.structureTitle}</p>
<p className="mt-2 text-sm font-semibold text-on-surface">
{models.length} {l.modelLabel.toLowerCase()} {" "}
{models.reduce((total, model) => total + getModelMeasurements(model).length, 0)} {l.variants}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5 shadow-sm xl:col-span-8">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="space-y-3">
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-2xl bg-surface-container-low">
{selectedImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={selectedImageUrl} alt={selectedModel?.name || "variant"} className="h-full w-full object-cover" />
) : (
<span className="material-symbols-outlined text-5xl text-outline/30">image</span>
)}
</div>
{productImages.length > 1 ? (
<div className="grid grid-cols-4 gap-2">
{productImages.slice(0, 4).map((imageId, index) => {
const image = imgUrl(imageId);
if (!image) return null;
return (
<div key={`${imageId}-${index}`} className="aspect-square overflow-hidden rounded-xl border border-surface-container bg-surface-container-low">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={`gallery-${index + 1}`} className="h-full w-full object-cover" />
</div>
);
})}
</div>
) : null}
</div>
<div className="space-y-5">
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">{l.modelLabel}</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{models.map((model, index) => {
const active = index === selectedModelIndex;
const measurementCount = getModelMeasurements(model).length;
const thumb = imgUrl(model.imageId || selectedImageId);
return (
<button
key={`${model.sku || model.name || index}`}
type="button"
onClick={() => {
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"
}`}
>
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-xl bg-surface-container">
{thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumb} alt={model.name || `model-${index + 1}`} className="h-full w-full object-cover" />
) : (
<span className="material-symbols-outlined text-outline/30">image</span>
)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-black text-on-surface">{model.name || `Model ${index + 1}`}</p>
<p className="mt-1 text-xs font-semibold text-primary">{getModelPriceLabel(model)}</p>
<p className="mt-1 text-[10px] font-bold uppercase tracking-widest text-outline">
{measurementCount > 0 ? `${measurementCount} ${l.variants}` : "Tanpa variasi ukuran"}
</p>
</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">{l.measurementLabel}</p>
{hasMeasurements ? (
<div className="flex flex-wrap gap-2">
{measurements.map((measurement, index) => {
const active = index === selectedMeasurementIndex;
return (
<button
key={`${measurement.measurementType || "measurement"}-${measurement.measurementValue || index}`}
type="button"
onClick={() => 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)}
</button>
);
})}
</div>
) : (
<div className="rounded-xl bg-surface-container-low p-3 text-sm font-semibold text-outline">
{l.noMeasurement}
</div>
)}
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-low p-4">
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">{l.selectedDetailTitle}</p>
<DetailRow label={l.modelLabel} value={selectedModel?.name || "-"} />
<DetailRow label={l.sku} value={selectedModel?.sku} />
{selectedMeasurement ? (
<DetailRow label={l.measurementLabel} value={buildMeasurementLabel(selectedMeasurement, selectedMeasurementIndex)} />
) : null}
<DetailRow label={l.price} value={selectedPrice} />
<DetailRow label={l.weight} value={selectedWeight} />
<DetailRow label={l.dimension} value={selectedDimension} />
<DetailRow label={l.promoPrice} value={selectedPromotionPrice} />
</div>
{selectedWarehouses.length > 0 ? (
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-low p-4">
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">{l.stock}</p>
<div className="space-y-2">
{selectedWarehouses.map((warehouse: VariantWarehouseLike, index) => (
<div key={`${warehouse.id || "warehouse"}-${index}`} className="flex justify-between gap-4 text-sm">
<span className="text-on-surface-variant">{getWarehouseLabel(warehouse, warehouseLabelResolver)}</span>
<span className="font-black text-on-surface">{warehouse.stock ?? 0} unit</span>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
);
}

206
src/lib/product-variants.ts Normal file
View File

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

View File

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

View File

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