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"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
async redirects() {
return [
{
source: "/onboarding/:path*",
destination: "/dashboard",
permanent: false,
},
];
},
images: { images: {
unoptimized: true, unoptimized: true,
remotePatterns: [ remotePatterns: [

View File

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

View File

@ -6,9 +6,13 @@ import { FormEvent, useState } from "react";
import { LanguageToggle } from "@/components/language-toggle"; import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context"; 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() { export default function ForgotPasswordPage() {
const { t } = useLanguage(); const { t } = useLanguage();
const f = t.auth.forgotPassword; const f = t.auth.forgotPassword;
const supportEmail = "admin@inatrading.co.id";
const [contact, setContact] = useState(""); const [contact, setContact] = useState("");
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@ -86,23 +90,30 @@ export default function ForgotPasswordPage() {
> >
{f.emailOrPhone} {f.emailOrPhone}
</label> </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"> <div className={recoveryFieldWrapperClass}>
<span className="material-symbols-outlined text-outline group-focus-within:text-primary mr-3"> <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">
alternate_email <span className="material-symbols-outlined text-[28px]">
</span> alternate_email
<input </span>
id="recovery-contact" </div>
name="recovery-contact" <div className="min-w-0 flex-1">
type="text" <p className="mb-1 text-[10px] font-black uppercase tracking-[0.22em] text-outline/80">
value={contact} Recovery Contact
onChange={(e) => setContact(e.target.value)} </p>
placeholder="name@company.com" <input
required id="recovery-contact"
className="w-full bg-transparent border-none p-0 text-lg font-medium text-on-surface placeholder:text-outline/50 focus:ring-0" 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> </div>
<p className="mt-3 text-sm text-on-surface-variant/70 italic"> <p className="mt-3 text-sm text-on-surface-variant/70">
{/* privacy note kept short */} Gunakan email bisnis atau nomor HP yang terdaftar di akun Anda.
</p> </p>
</div> </div>
@ -132,7 +143,12 @@ export default function ForgotPasswordPage() {
<span className="material-symbols-outlined text-tertiary">contact_support</span> <span className="material-symbols-outlined text-tertiary">contact_support</span>
<p className="text-sm text-on-surface-variant"> <p className="text-sm text-on-surface-variant">
{f.havingTrouble}{" "} {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> </p>
</div> </div>
</footer> </footer>
@ -143,9 +159,13 @@ export default function ForgotPasswordPage() {
</main> </main>
<div className="fixed bottom-8 right-8 z-50"> <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> <span className="material-symbols-outlined">help_center</span>
</button> </Link>
</div> </div>
</> </>
); );

View File

@ -2,13 +2,14 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { LanguageToggle } from "@/components/language-toggle"; import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
const authFieldWrapperClass = 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"; "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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@ -22,6 +23,29 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@ -46,9 +70,14 @@ export default function LoginPage() {
} }
if (remember) { if (remember) {
localStorage.setItem(
rememberedCredentialsKey,
JSON.stringify({ email, password })
);
localStorage.setItem("token", data.token); localStorage.setItem("token", data.token);
localStorage.setItem("role", data.role); localStorage.setItem("role", data.role);
} else { } else {
localStorage.removeItem(rememberedCredentialsKey);
sessionStorage.setItem("token", data.token); sessionStorage.setItem("token", data.token);
sessionStorage.setItem("role", data.role); sessionStorage.setItem("role", data.role);
} }
@ -59,6 +88,26 @@ export default function LoginPage() {
} }
if (data.role === "seller") { 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"); router.push("/dashboard");
return; return;
} }
@ -175,11 +224,18 @@ export default function LoginPage() {
type="checkbox" type="checkbox"
id="remember" id="remember"
checked={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" 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"> <label htmlFor="remember" className="text-sm font-medium text-on-surface-variant">
{l.rememberDevice} {l.rememberMe}
</label> </label>
</div> </div>

View File

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

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
interface AnalyticsPoint { interface AnalyticsPoint {
@ -31,6 +32,25 @@ interface SellerDashboardPayload {
recentOrders: DashboardOrder[]; 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() { function getToken() {
if (typeof window === "undefined") return ""; if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || ""; return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
@ -52,10 +72,14 @@ function statusColor(status: string) {
return "text-primary bg-primary/10"; return "text-primary bg-primary/10";
} }
export default function DashboardPage() { function DashboardContent() {
const searchParams = useSearchParams();
const { t } = useLanguage(); const { t } = useLanguage();
const d = t.dashboard.overview; const d = t.dashboard.overview;
const [data, setData] = useState<SellerDashboardPayload | null>(null); 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 [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -84,8 +108,101 @@ export default function DashboardPage() {
const analytics = data?.analytics || []; const analytics = data?.analytics || [];
const maxAnalytics = Math.max(...analytics.map((item) => item.value), 1); const maxAnalytics = Math.max(...analytics.map((item) => item.value), 1);
const totalRevenue = (data?.recentOrders || []).reduce((sum, item) => sum + item.amount, 0); const rawDashboardQuery = (searchParams.get("q") || "").trim();
const totalOrders = data?.recentOrders.length || 0; 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 ( return (
<div className="p-8"> <div className="p-8">
@ -96,6 +213,101 @@ export default function DashboardPage() {
<p className="text-on-surface-variant font-medium">{d.subtitle}</p> <p className="text-on-surface-variant font-medium">{d.subtitle}</p>
</div> </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="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="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> <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="bg-surface-container-lowest rounded-xl magazine-shadow overflow-hidden">
<div className="p-8 flex items-center justify-between border-b border-surface-container"> <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"> <button className="text-primary font-bold text-sm hover:underline flex items-center gap-1">
{d.viewAll} {d.viewAll}
<span className="material-symbols-outlined text-sm">arrow_forward</span> <span className="material-symbols-outlined text-sm">arrow_forward</span>
@ -239,7 +458,7 @@ export default function DashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-surface-container"> <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"> <tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors">
<td className="px-8 py-5"> <td className="px-8 py-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -270,10 +489,10 @@ export default function DashboardPage() {
</td> </td>
</tr> </tr>
))} ))}
{!loading && !error && (data?.recentOrders || []).length === 0 ? ( {!loading && !error && filteredOrders.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-8 py-10 text-center text-sm font-semibold text-on-surface-variant"> <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> </td>
</tr> </tr>
) : null} ) : null}
@ -290,3 +509,11 @@ export default function DashboardPage() {
</div> </div>
); );
} }
export default function DashboardPage() {
return (
<Suspense>
<DashboardContent />
</Suspense>
);
}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { FormEvent, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@ -43,6 +44,13 @@ export default function DashboardLayout({
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { t } = useLanguage(); 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 = () => { const handleLogout = () => {
localStorage.removeItem("token"); localStorage.removeItem("token");
@ -52,29 +60,58 @@ export default function DashboardLayout({
router.push("/login"); 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 ( return (
<div className="min-h-screen bg-surface"> <div className="min-h-screen bg-surface">
{/* Top Nav */} {/* 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"> <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"> <div className="flex items-center gap-8">
<Image src="/ina_logo.png" alt="Ina Trading" width={120} height={32} priority /> <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" /> <AppIcon name="search" className="h-5 w-5 text-on-surface-variant" />
<input <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" className="bg-transparent border-none outline-none text-sm w-64 text-on-surface placeholder:text-on-surface-variant"
placeholder={t.dashboard.layout.searchPlaceholder} placeholder={t.dashboard.layout.searchPlaceholder}
type="text" type="text"
enterKeyHint="search"
/> />
</div> </form>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageToggle /> <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" /> <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>
<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" /> <AppIcon name="chat" className="h-5 w-5" />
</button> </button>
</div> </div>

View File

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
@ -37,6 +38,20 @@ interface ProductMeasurement {
} }
interface ProductModel { 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[]; warehouses?: ProductWarehouse[];
productMeasurements?: ProductMeasurement[]; productMeasurements?: ProductMeasurement[];
} }
@ -440,100 +455,14 @@ function ProductDetailPageInner() {
{/* ── Section 03: Pricing & Model ───────────────────────────────────── */} {/* ── Section 03: Pricing & Model ───────────────────────────────────── */}
{models.length > 0 && ( {models.length > 0 && (
<div className="space-y-5"> <div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
<div className="flex items-center gap-4"> <SectionHeader step="03" title={`${d.section03} (${models.length})`} />
<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> <ProductVariantShowcase
<h2 className="text-xl font-black font-headline tracking-tight text-on-surface">{d.section03} ({models.length})</h2> product={product}
</div> warehouseLabelResolver={(warehouse) =>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} warehouse.id ? warehouseMap[String(warehouse.id)] || String(warehouse.id) : warehouse.name || "-"
{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> </div>
)} )}

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import { Suspense, useEffect, useRef, useState } from "react"; import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
import { getProductEffectivePoints } from "@/lib/product-variants";
type TabLabel = type TabLabel =
| "All Product" | "All Product"
@ -28,6 +29,7 @@ interface ProductRow {
status?: string | null; status?: string | null;
reviewStatus?: string | null; reviewStatus?: string | null;
totalStock: number; totalStock: number;
productModels?: ProductModelRef[];
} }
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string; type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
@ -103,17 +105,89 @@ function getToken() {
} }
function formatPrice(product: ProductRow) { function formatPrice(product: ProductRow) {
if (!product.minPrice && !product.maxPrice) { const minPrice = Number(product.minPrice);
return "-"; 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"); const formatter = new Intl.NumberFormat("id-ID");
if (prices[0] === prices[prices.length - 1]) {
if (product.minPrice === product.maxPrice) { return `Rp ${formatter.format(prices[0])}`;
return `Rp ${formatter.format(product.minPrice)}`;
} }
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) { function marketClasses(market: string) {
@ -745,9 +819,10 @@ function ProductsPageInner() {
if (tab) params.set("tab", tab); if (tab) params.set("tab", tab);
params.set("page", String(page)); params.set("page", String(page));
params.set("size", "20"); params.set("size", "20");
const token = getToken();
const res = await fetch(`/api/products?${params.toString()}`, const res = await fetch(`/api/products?${params.toString()}`,
{ headers: { "x-auth-token": getToken() } } { headers: { "x-auth-token": token } }
); );
const result = await res.json(); const result = await res.json();
@ -755,7 +830,13 @@ function ProductsPageInner() {
throw new Error(result?.responseDesc || "Failed to load products"); 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); setTotalItem(result?.totalItem || result?.data?.totalItem || 0);
setTotalPage(result?.totalPage || result?.data?.totalPage || 0); setTotalPage(result?.totalPage || result?.data?.totalPage || 0);
} catch (err) { } catch (err) {

View File

@ -2,15 +2,13 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { LanguageToggle } from "@/components/language-toggle"; import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context"; import { useLanguage } from "@/lib/i18n-context";
const steps = [ const steps = [
{ href: "/onboarding/business", icon: "storefront", labelKey: "business" as const, step: 1 }, { 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/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({ export default function OnboardingLayout({
@ -19,7 +17,6 @@ export default function OnboardingLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const { t } = useLanguage(); const { t } = useLanguage();
const lo = t.onboarding.layout; const lo = t.onboarding.layout;
@ -27,16 +24,6 @@ export default function OnboardingLayout({
const currentStep = steps.find((s) => pathname.startsWith(s.href)); const currentStep = steps.find((s) => pathname.startsWith(s.href));
const stepNumber = currentStep?.step ?? (isSuccessPage ? steps.length : 1); 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) { if (isSuccessPage) {
return ( return (
<div className="min-h-screen bg-surface text-on-surface font-body antialiased"> <div className="min-h-screen bg-surface text-on-surface font-body antialiased">

View File

@ -1,371 +1,14 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; 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() { export default function PlanPage() {
const router = useRouter(); 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(() => { useEffect(() => {
const token = getToken(); router.replace("/onboarding/store-detail");
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]); }, [router]);
function persistPlan(planId: PlanId) { return null;
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>
</>
);
} }

View File

@ -17,8 +17,6 @@ const headlineFieldClass =
const textareaClass = 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"; "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) { function toNumber(value: string) {
const normalized = value.trim(); const normalized = value.trim();
if (!normalized) return 0; if (!normalized) return 0;
@ -26,10 +24,21 @@ function toNumber(value: string) {
return Number.isFinite(parsed) ? parsed : 0; 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() { export default function StoreDetailPage() {
const router = useRouter(); const router = useRouter();
const { t } = useLanguage(); const { t } = useLanguage();
const sd = t.onboarding.storeDetail; const sd = t.onboarding.storeDetail;
const common = t.common;
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -42,7 +51,7 @@ export default function StoreDetailPage() {
const [warehouse, setWarehouse] = useState({ const [warehouse, setWarehouse] = useState({
name: "", name: "",
address: "", address: "",
warehouseType: "INA" as WarehouseType, warehouseType: "INA",
country: "Indonesia", country: "Indonesia",
province: "", province: "",
city: "", city: "",
@ -115,7 +124,7 @@ export default function StoreDetailPage() {
} }
}, [router]); }, [router]);
function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
setSubmitting(true); 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( sessionStorage.setItem(
ONBOARDING_STORE_STORAGE_KEY, ONBOARDING_STORE_STORAGE_KEY,
JSON.stringify({ 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) { } catch (err) {
setError(err instanceof Error ? err.message : sd.genericError); setError(
err instanceof Error ? err.message : common.connectionError
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -299,25 +416,6 @@ export default function StoreDetailPage() {
/> />
</div> </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> <div>
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline"> <label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
{sd.country} {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"> <p className="text-on-surface-variant leading-relaxed mb-8 max-w-sm">
Profil UMKM institusional Anda telah tercatat dan sedang ditinjau Profil UMKM institusional Anda telah tercatat dan sedang ditinjau
oleh tim verifikasi kami. Setelah disetujui, Anda akan mendapat akses oleh tim verifikasi kami. Setelah disetujui, Anda akan mendapat akses
penuh ke protokol{" "} penuh ke Dashboard seller Ina Tarading. Lanjutkan ke dashboard
<span className="text-tertiary font-semibold">Trading Desk Alpha</span>. untuk memantau status Anda.
Lanjutkan ke dashboard untuk memantau status Anda.
</p> </p>
<Link <Link

View File

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; 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"> <div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="05" title="Models and Pricing" /> <SectionHeader step="05" title="Models and Pricing" />
{models.length ? ( <ProductVariantShowcase
<div className="space-y-4"> product={product}
{models.map((model, index) => ( warehouseLabelResolver={(warehouse) =>
<div key={`${model.sku || model.name || index}`} className="rounded-2xl border border-surface-container p-5"> [warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
<div className="mb-4 flex items-center justify-between gap-4"> }
<div> labels={{
<p className="text-sm font-black text-on-surface">{model.name || `Model ${index + 1}`}</p> summaryTitle: "Variant Summary",
<p className="mt-1 text-xs text-outline">SKU: {model.sku || "-"}</p> structureTitle: "Variant Structure",
</div> productPrice: "Product Price",
</div> productWeight: "Product Weight",
productDimension: "Product Dimension",
<div className="space-y-4"> selectedDetailTitle: "Selected Variant Detail",
{(Array.isArray(model.productMeasurements) ? model.productMeasurements : []).map((measurement, measurementIndex) => ( stock: "Warehouses",
<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>
)}
</div> </div>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2"> <div className="grid grid-cols-1 gap-8 xl:grid-cols-2">

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react"; import { Suspense, useEffect, useRef, useState } from "react";
@ -50,7 +51,7 @@ function formatMeasurementLabel(
index: number index: number
) { ) {
const parts = [measurement.measurementType, measurement.measurementValue].filter(Boolean); 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 { interface ReviewWarehouse {
@ -86,6 +87,11 @@ interface ReviewModel extends ReviewMeasurement {
productMeasurements?: ReviewMeasurement[]; productMeasurements?: ReviewMeasurement[];
} }
interface ReviewInfoItem {
paramName: string;
paramValue: string;
}
interface ReviewProductData { interface ReviewProductData {
name?: string; name?: string;
description?: string; description?: string;
@ -100,6 +106,8 @@ interface ReviewProductData {
productModels?: ReviewModel[]; productModels?: ReviewModel[];
productKeyWords?: string[]; productKeyWords?: string[];
productFeatures?: string[]; productFeatures?: string[];
productInformations?: ReviewInfoItem[];
categoryInformations?: ReviewInfoItem[];
subCategory?: { subCategory?: {
name?: string; name?: string;
category?: { category?: {
@ -162,39 +170,43 @@ function ModelCard({
const modelDimension = formatDimension(model.length, model.width, model.height, model.dimensionType); const modelDimension = formatDimension(model.length, model.width, model.height, model.dimensionType);
return ( 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"> <div className="flex items-center gap-3 mb-3">
{imgUrl(model.imageId) && ( {imgUrl(model.imageId) && (
// eslint-disable-next-line @next/next/no-img-element // 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> <div>
<p className="font-black text-sm text-on-surface">{model.name || `Model ${index + 1}`}</p> <p className="font-black text-sm text-on-surface">{model.name || `Model ${index + 1}`}</p>
{changed && ( {changed && (
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-amber-700"> <p className="mt-1 text-[10px] font-black uppercase tracking-widest text-amber-700">
Updated Diperbarui
</p> </p>
)} )}
{hasMeasurements && ( {hasMeasurements && (
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-primary"> <p className="mt-1 text-[10px] font-black uppercase tracking-widest text-primary">
{measurements.length} measurement variation(s) {measurements.length} variasi ukuran
</p> </p>
)} )}
</div> </div>
</div> </div>
<Row label="SKU" value={model.sku} /> <Row label="SKU" value={model.sku} />
<Row label="Harga" value={hasMeasurements ? "Menggunakan harga measurement" : modelPrice} /> {!hasMeasurements ? (
<Row label="Berat" value={hasMeasurements ? "Menggunakan berat measurement" : modelWeight} /> <>
<Row label="Dimensi" value={hasMeasurements ? "Menggunakan dimensi measurement" : modelDimension} /> <Row label="Harga" value={modelPrice} />
{model.isConfigurePromotionPrice && !hasMeasurements && ( <Row label="Berat" value={modelWeight} />
<Row label="Harga Promo" value={modelPromotionPrice} /> <Row label="Dimensi" value={modelDimension} />
)} {model.isConfigurePromotionPrice && (
<Row label="Harga Promo" value={modelPromotionPrice} />
)}
</>
) : null}
{!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && ( {!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100"> <div className="mt-3 pt-3 border-t border-surface-container">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p> <p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">Warehouse & Stok</p>
{model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => ( {model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1"> <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> <span className="font-black">{warehouse.stock ?? 0} unit</span>
</div> </div>
))} ))}
@ -202,7 +214,7 @@ function ModelCard({
)} )}
{hasMeasurements && ( {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) => { {measurements.map((measurement: ReviewMeasurement, measurementIndex: number) => {
const measurementPrice = const measurementPrice =
formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-"; formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-";
@ -221,7 +233,7 @@ function ModelCard({
return ( return (
<div <div
key={`${measurement.measurementType || "measurement"}-${measurement.measurementValue || measurementIndex}`} 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"> <p className="mb-3 text-xs font-black uppercase tracking-wider text-on-surface">
{formatMeasurementLabel(measurement, measurementIndex)} {formatMeasurementLabel(measurement, measurementIndex)}
@ -233,11 +245,11 @@ function ModelCard({
<Row label="Harga Promo" value={measurementPromotionPrice} /> <Row label="Harga Promo" value={measurementPromotionPrice} />
)} )}
{Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && ( {Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100"> <div className="mt-3 pt-3 border-t border-surface-container">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p> <p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">Warehouse & Stok</p>
{measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => ( {measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1"> <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> <span className="font-black">{warehouse.stock ?? 0} unit</span>
</div> </div>
))} ))}
@ -258,13 +270,39 @@ function Row({ label, value }: { label: string; value?: string | number | boolea
if (value === "" || value === undefined || value === null) return null; if (value === "" || value === undefined || value === null) return null;
const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value); const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value);
return ( return (
<div className="flex justify-between gap-4 py-2.5 border-b border-slate-100 last:border-0 text-sm"> <div className="flex justify-between gap-4 py-2 border-b border-surface-container last:border-0 text-sm">
<span className="text-slate-500 font-medium flex-shrink-0">{label}</span> <span className="text-on-surface-variant font-medium flex-shrink-0">{label}</span>
<span className="font-semibold text-on-surface text-right">{display}</span> <span className="font-semibold text-on-surface text-right">{display}</span>
</div> </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({ function SectionCard({
title, title,
accent, accent,
@ -277,14 +315,14 @@ function SectionCard({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( 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={`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" : accent ? "border-primary/20" : "border-slate-100"}`}> <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" : accent ? "text-primary" : "text-slate-400"}`}> <h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : "text-outline"}`}>
{title} {title}
</h3> </h3>
{changed && ( {changed && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-amber-800"> <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> </span>
)} )}
</div> </div>
@ -355,6 +393,12 @@ function ProductColumn({
const images = Array.isArray(product.productImages) ? product.productImages : []; const images = Array.isArray(product.productImages) ? product.productImages : [];
const features = Array.isArray(product.productFeatures) ? product.productFeatures : []; const features = Array.isArray(product.productFeatures) ? product.productFeatures : [];
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : []; 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[] = [ const allImages: string[] = [
...(product.imageId ? [product.imageId] : []), ...(product.imageId ? [product.imageId] : []),
...images ...images
@ -368,21 +412,19 @@ function ProductColumn({
return ( return (
<div className="flex-1 min-w-0 space-y-4"> <div className="flex-1 min-w-0 space-y-4">
{/* Column label */} <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"}`}>
<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"}`}>
{label} {label}
</div> </div>
{/* Images */}
{allImages.length > 0 && ( {allImages.length > 0 && (
<SectionCard title="Gambar Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}> <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) => { {allImages.map((imageId: string, i: number) => {
const url = imgUrl(imageId); const url = imgUrl(imageId);
if (!url) return null; if (!url) return null;
return ( return (
// eslint-disable-next-line @next/next/no-img-element // 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> </div>
@ -408,33 +450,30 @@ function ProductColumn({
<Row label="Deskripsi" value={product.description} /> <Row label="Deskripsi" value={product.description} />
<Row label="Kategori" value={product.subCategory?.category?.name} /> <Row label="Kategori" value={product.subCategory?.category?.name} />
<Row label="Sub Kategori" value={product.subCategory?.name} /> <Row label="Sub Kategori" value={product.subCategory?.name} />
<Row label="Brand New" value={product.isNew} /> <Row label="Produk Baru" value={product.isNew} />
<Row label="Eligible to Export" value={product.isEligibleToExport} /> <Row label="Bisa Diekspor" value={product.isEligibleToExport} />
<Row label="Pre-order" value={product.isPreOrder} /> <Row label="Pre-order" value={product.isPreOrder} />
{product.isPreOrder && <Row label="Pre-order Days" value={product.preOrderDay} />} {product.isPreOrder && <Row label="Durasi Pre-order" value={product.preOrderDay} />}
<Row label="State" value={product.state} /> <Row label="Status" value={product.state} />
</SectionCard> </SectionCard>
{/* Features */} {/* Features */}
{features.length > 0 && ( {features.length > 0 && (
<SectionCard title="Fitur Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["productFeatures"])}> <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) => ( {features.map((f: string, i: number) => (
<li key={i} className="flex items-start gap-2 text-sm"> <span key={i} className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed">{f}</span>
<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>
))} ))}
</ul> </div>
</SectionCard> </SectionCard>
)} )}
{/* Keywords */} {/* Keywords */}
{keywords.length > 0 && ( {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"> <div className="flex flex-wrap gap-2">
{keywords.map((k: string) => ( {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> </div>
</SectionCard> </SectionCard>
@ -443,26 +482,21 @@ function ProductColumn({
{/* Models */} {/* Models */}
{models.length > 0 && ( {models.length > 0 && (
<SectionCard title={`Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}> <SectionCard title={`Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}>
<div className="space-y-3"> <ProductVariantShowcase
{models.map((model: ReviewModel, index: number) => ( product={product}
<ModelCard warehouseLabelResolver={(warehouse) =>
key={`${model.sku || model.name || index}`} [warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
model={model} }
index={index} />
accent={accent}
changed={hasChangesForPaths(compareRows, ["productModels"])}
/>
))}
</div>
</SectionCard> </SectionCard>
)} )}
{/* Compliance */} {/* Compliance */}
{product.complianceInformation && ( {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="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} /> <Row label="Peringatan Keamanan" value={product.complianceInformation.safetyWarning} />
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} /> <Row label="Barang Berbahaya" value={product.complianceInformation.isDangerousGoodRegulation} />
</SectionCard> </SectionCard>
)} )}
@ -614,6 +648,25 @@ function AdminReviewDetailPageInner() {
</div> </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 ──────────────────────────────────────────────────────── // ── Reject modal ────────────────────────────────────────────────────────
const rejectModal = showRejectModal && ( const rejectModal = showRejectModal && (
@ -660,59 +713,320 @@ function AdminReviewDetailPageInner() {
<> <>
{!isReadonly ? rejectModal : null} {!isReadonly ? rejectModal : null}
<div className="m-6 space-y-6 pb-10"> <div className="space-y-8 pb-16">
{/* Header */} <div className="mb-2">
<div className="flex items-start justify-between"> <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> <div>
<nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center"> <div className="flex items-start justify-between gap-4">
<button onClick={() => router.push(backHref)} className="hover:text-primary transition-colors"> <div>
{isReadonly ? "Products" : "Reviews"} <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> </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>
</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> </div>
{/* Comparison notice */} <div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
{isComparison && ( <SectionHeader step="01" title="Ringkasan Review" />
<div className="p-4 bg-secondary-fixed/50 rounded-xl flex items-center gap-3 text-sm font-semibold text-on-secondary-fixed-variant"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<span className="material-symbols-outlined text-secondary">compare_arrows</span> <div className="rounded-xl bg-surface-container-low p-4">
Bandingkan perubahan yang diajukan seller (kiri) dengan data produk saat ini (kanan). <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>
)} </div>
{/* Content — 1 column (isNew) or 2 columns (update) */}
{isComparison ? ( {isComparison ? (
<div className="flex gap-6 items-start"> <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">
<ProductColumn product={product} label="Versi Terbaru (Diajukan)" accent compareRows={compareRows} /> <div className="flex items-center gap-3">
<ProductColumn product={oldProduct} label="Versi Saat Ini (Live)" /> <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>
) : ( ) : (
<div className="space-y-4"> <>
<ProductColumn product={product} label="Produk Baru" accent /> <div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
</div> <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 && isComparison ? (
{product.seller && ( <div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-5"> <SectionHeader step={isComparison ? "04" : "03"} title="Penjual" />
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-slate-400 mb-4 pb-3 border-b border-slate-100">Seller</h3>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{imgUrl(product.seller.imageId) ? ( {imgUrl(product.seller.imageId) ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@ -728,11 +1042,11 @@ function AdminReviewDetailPageInner() {
</div> </div>
</div> </div>
</div> </div>
)} ) : null}
{/* Action bar */}
{!isReadonly ? ( {!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 && ( {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"> <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> <span className="material-symbols-outlined text-tertiary">check_circle</span>
@ -745,32 +1059,36 @@ function AdminReviewDetailPageInner() {
{actionError} {actionError}
</div> </div>
)} )}
{acting && (
<p className="text-xs text-slate-400 flex items-center gap-1.5"> <div className="flex flex-col items-end gap-3">
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span> {acting ? (
Memproses review... <p className="text-xs text-slate-400 flex items-center gap-1.5">
</p> <span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
)} Memproses review...
{!actionSuccess && ( </p>
<div className="flex items-center gap-4"> ) : null}
<button
onClick={() => { setShowRejectModal(true); setActionError(""); }} {!actionSuccess ? (
disabled={acting} <div className="flex items-center gap-4">
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" <button
> onClick={() => { setShowRejectModal(true); setActionError(""); }}
<span className="material-symbols-outlined text-base">block</span> disabled={acting}
{isComparison ? "Reject Update" : "Reject Product"} 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"
</button> >
<button <span className="material-symbols-outlined text-base">block</span>
onClick={() => submitReview("accept")} {isComparison ? "Tolak Update" : "Tolak Produk"}
disabled={acting} </button>
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" <button
> onClick={() => submitReview("accept")}
<span className="material-symbols-outlined text-base">check_circle</span> disabled={acting}
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"} 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"
</button> >
</div> <span className="material-symbols-outlined text-base">check_circle</span>
)} {acting ? "Memproses..." : isComparison ? "Setujui Update" : "Setujui Produk"}
</button>
</div>
) : null}
</div>
</div> </div>
) : null} ) : null}
</div> </div>
@ -780,7 +1098,7 @@ function AdminReviewDetailPageInner() {
export default function AdminReviewDetailPage() { export default function AdminReviewDetailPage() {
return ( 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 /> <AdminReviewDetailPageInner />
</Suspense> </Suspense>
); );

View File

@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
const endpointMap: Record<string, string> = { const endpointMap: Record<string, string> = {
draft: "/api/v1.0/seller/draft/product", 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", "international-market": "/api/v1.0/seller/international/product",
"local-market": "/api/v1.0/seller/local/product", "local-market": "/api/v1.0/seller/local/product",
"out-of-stock": "/api/v1.0/seller/outofstock/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", emailOrPhone: "Email or Phone Number",
password: "Password", password: "Password",
forgotPassword: "Forgot password?", forgotPassword: "Forgot password?",
rememberDevice: "Remember this device for 30 days", rememberMe: "Remember me",
submit: "Sign In", submit: "Sign In",
submitting: "Processing...", submitting: "Processing...",
noAccount: "Don't have an account?", noAccount: "Don't have an account?",
@ -80,7 +80,7 @@ export const en = {
verifyFail: "Verification failed", verifyFail: "Verification failed",
registerFail: "Seller registration failed", registerFail: "Seller registration failed",
successSeller: successSeller:
"OTP valid and seller account created. Redirecting to dashboard...", "OTP valid and seller account created. Redirecting to onboarding...",
successBuyer: successBuyer:
"OTP successfully verified. Redirecting to the next step...", "OTP successfully verified. Redirecting to the next step...",
securityTitle: "Institutional-Grade Security", securityTitle: "Institutional-Grade Security",

View File

@ -19,7 +19,7 @@ export const id = {
emailOrPhone: "Email atau Nomor HP", emailOrPhone: "Email atau Nomor HP",
password: "Password", password: "Password",
forgotPassword: "Lupa password?", forgotPassword: "Lupa password?",
rememberDevice: "Ingat perangkat ini selama 30 hari", rememberMe: "Ingat saya",
submit: "Masuk", submit: "Masuk",
submitting: "Memproses...", submitting: "Memproses...",
noAccount: "Belum punya akun?", noAccount: "Belum punya akun?",
@ -81,7 +81,7 @@ export const id = {
verifyFail: "Verifikasi gagal", verifyFail: "Verifikasi gagal",
registerFail: "Registrasi seller gagal", registerFail: "Registrasi seller gagal",
successSeller: successSeller:
"OTP valid dan akun seller berhasil dibuat. Mengalihkan ke dashboard...", "OTP valid dan akun seller berhasil dibuat. Mengalihkan ke onboarding...",
successBuyer: successBuyer:
"OTP berhasil diverifikasi. Mengalihkan ke langkah berikutnya...", "OTP berhasil diverifikasi. Mengalihkan ke langkah berikutnya...",
securityTitle: "Keamanan Tingkat Institusional", securityTitle: "Keamanan Tingkat Institusional",