Refine seller onboarding and product review flows
This commit is contained in:
198
HANDOFF.md
Normal file
198
HANDOFF.md
Normal 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`
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/onboarding/:path*",
|
||||
destination: "/dashboard",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
|
||||
@ -41,7 +41,7 @@ function AccountNotFoundContent() {
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageToggle />
|
||||
<Link
|
||||
href="/login"
|
||||
href="/help"
|
||||
className="hidden sm:flex text-secondary font-semibold hover:text-primary transition-colors items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">help_outline</span>
|
||||
@ -104,12 +104,12 @@ function AccountNotFoundContent() {
|
||||
<footer className="mt-auto pt-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
<Link href="/privacy" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
{t.common.privacy}
|
||||
</a>
|
||||
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
</Link>
|
||||
<Link href="/terms" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
{t.common.terms}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-outline/60 max-w-sm">{a.disclaimer}</p>
|
||||
</div>
|
||||
|
||||
@ -6,9 +6,13 @@ import { FormEvent, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const recoveryFieldWrapperClass =
|
||||
"group flex items-center rounded-2xl border border-outline-variant/50 bg-white px-5 py-4 shadow-[0_14px_30px_rgba(95,9,13,0.06)] transition-all duration-200 focus-within:border-primary focus-within:shadow-[0_18px_36px_rgba(183,19,26,0.12)]";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useLanguage();
|
||||
const f = t.auth.forgotPassword;
|
||||
const supportEmail = "admin@inatrading.co.id";
|
||||
|
||||
const [contact, setContact] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@ -86,23 +90,30 @@ export default function ForgotPasswordPage() {
|
||||
>
|
||||
{f.emailOrPhone}
|
||||
</label>
|
||||
<div className="flex items-center rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-4 transition-all group-focus-within:border-primary group-focus-within:bg-surface-container-lowest">
|
||||
<span className="material-symbols-outlined text-outline group-focus-within:text-primary mr-3">
|
||||
alternate_email
|
||||
</span>
|
||||
<input
|
||||
id="recovery-contact"
|
||||
name="recovery-contact"
|
||||
type="text"
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
className="w-full bg-transparent border-none p-0 text-lg font-medium text-on-surface placeholder:text-outline/50 focus:ring-0"
|
||||
/>
|
||||
<div className={recoveryFieldWrapperClass}>
|
||||
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/8 text-primary transition-colors group-focus-within:bg-primary/12">
|
||||
<span className="material-symbols-outlined text-[28px]">
|
||||
alternate_email
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-[0.22em] text-outline/80">
|
||||
Recovery Contact
|
||||
</p>
|
||||
<input
|
||||
id="recovery-contact"
|
||||
name="recovery-contact"
|
||||
type="text"
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
className="w-full border-none bg-transparent p-0 text-xl font-semibold tracking-tight text-on-surface placeholder:text-outline/45 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-on-surface-variant/70 italic">
|
||||
{/* privacy note kept short */}
|
||||
<p className="mt-3 text-sm text-on-surface-variant/70">
|
||||
Gunakan email bisnis atau nomor HP yang terdaftar di akun Anda.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -132,7 +143,12 @@ export default function ForgotPasswordPage() {
|
||||
<span className="material-symbols-outlined text-tertiary">contact_support</span>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{f.havingTrouble}{" "}
|
||||
<a href="#" className="text-tertiary font-bold hover:underline">{f.supportLink}</a>
|
||||
<a
|
||||
href={`mailto:${supportEmail}?subject=${encodeURIComponent("Bantuan lupa password Ina Trading")}`}
|
||||
className="text-tertiary font-bold hover:underline"
|
||||
>
|
||||
{f.supportLink}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
@ -143,9 +159,13 @@ export default function ForgotPasswordPage() {
|
||||
</main>
|
||||
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<button className="bg-surface-container-lowest text-secondary p-4 rounded-full magazine-shadow border border-outline-variant/20 hover:bg-secondary hover:text-on-secondary transition-all active:scale-95 flex items-center justify-center">
|
||||
<Link
|
||||
href="/help"
|
||||
aria-label="Open help center"
|
||||
className="bg-surface-container-lowest text-secondary p-4 rounded-full magazine-shadow border border-outline-variant/20 hover:bg-secondary hover:text-on-secondary transition-all active:scale-95 flex items-center justify-center"
|
||||
>
|
||||
<span className="material-symbols-outlined">help_center</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const authFieldWrapperClass =
|
||||
"relative rounded-xl border border-outline-variant/60 bg-surface-container-high px-0 transition-all duration-300 focus-within:border-primary focus-within:bg-surface-container-lowest";
|
||||
const rememberedCredentialsKey = "rememberedLoginCredentials";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@ -22,6 +23,29 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const rawRememberedCredentials = localStorage.getItem(rememberedCredentialsKey);
|
||||
|
||||
if (!rawRememberedCredentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rememberedCredentials = JSON.parse(rawRememberedCredentials) as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
if (rememberedCredentials.email && rememberedCredentials.password) {
|
||||
setEmail(rememberedCredentials.email);
|
||||
setPassword(rememberedCredentials.password);
|
||||
setRemember(true);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(rememberedCredentialsKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
@ -46,9 +70,14 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
if (remember) {
|
||||
localStorage.setItem(
|
||||
rememberedCredentialsKey,
|
||||
JSON.stringify({ email, password })
|
||||
);
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("role", data.role);
|
||||
} else {
|
||||
localStorage.removeItem(rememberedCredentialsKey);
|
||||
sessionStorage.setItem("token", data.token);
|
||||
sessionStorage.setItem("role", data.role);
|
||||
}
|
||||
@ -59,6 +88,26 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
if (data.role === "seller") {
|
||||
try {
|
||||
const profileRes = await fetch("/api/seller/profile", {
|
||||
headers: { "x-auth-token": data.token },
|
||||
});
|
||||
const profileData = await profileRes.json();
|
||||
const profile = profileData?.data || profileData;
|
||||
|
||||
const isIncomplete =
|
||||
!profile?.storeName ||
|
||||
!profile?.biography ||
|
||||
!profile?.sellerImageUrl;
|
||||
|
||||
if (isIncomplete || data.onboardingRequired) {
|
||||
router.push("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to dashboard if the profile check fails.
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
@ -175,11 +224,18 @@ export default function LoginPage() {
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
onChange={(e) => {
|
||||
const nextRemember = e.target.checked;
|
||||
setRemember(nextRemember);
|
||||
|
||||
if (!nextRemember) {
|
||||
localStorage.removeItem(rememberedCredentialsKey);
|
||||
}
|
||||
}}
|
||||
className="w-5 h-5 rounded border-outline-variant text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="remember" className="text-sm font-medium text-on-surface-variant">
|
||||
{l.rememberDevice}
|
||||
{l.rememberMe}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@ -125,7 +125,7 @@ function VerifyContent() {
|
||||
sessionStorage.removeItem("otpVerified");
|
||||
sessionStorage.removeItem("otpVerifiedEmail");
|
||||
setSuccess(v.successSeller);
|
||||
setTimeout(() => { router.push("/dashboard"); }, 1000);
|
||||
setTimeout(() => { router.push("/onboarding/business"); }, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
interface AnalyticsPoint {
|
||||
@ -31,6 +32,25 @@ interface SellerDashboardPayload {
|
||||
recentOrders: DashboardOrder[];
|
||||
}
|
||||
|
||||
interface ProductSearchRow {
|
||||
id: string;
|
||||
name: string;
|
||||
market?: string | null;
|
||||
state?: string | null;
|
||||
status?: string | null;
|
||||
totalStock?: number | null;
|
||||
}
|
||||
|
||||
interface WarehouseSearchRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
country: string | null;
|
||||
warehouseType: string | null;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
@ -52,10 +72,14 @@ function statusColor(status: string) {
|
||||
return "text-primary bg-primary/10";
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
function DashboardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLanguage();
|
||||
const d = t.dashboard.overview;
|
||||
const [data, setData] = useState<SellerDashboardPayload | null>(null);
|
||||
const [productResults, setProductResults] = useState<ProductSearchRow[]>([]);
|
||||
const [warehouseResults, setWarehouseResults] = useState<WarehouseSearchRow[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@ -84,8 +108,101 @@ export default function DashboardPage() {
|
||||
|
||||
const analytics = data?.analytics || [];
|
||||
const maxAnalytics = Math.max(...analytics.map((item) => item.value), 1);
|
||||
const totalRevenue = (data?.recentOrders || []).reduce((sum, item) => sum + item.amount, 0);
|
||||
const totalOrders = data?.recentOrders.length || 0;
|
||||
const rawDashboardQuery = (searchParams.get("q") || "").trim();
|
||||
const dashboardQuery = rawDashboardQuery.toLowerCase();
|
||||
const filteredOrders = (data?.recentOrders || []).filter((order) => {
|
||||
if (!dashboardQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
order.product,
|
||||
order.sku,
|
||||
order.customer,
|
||||
order.location,
|
||||
order.date,
|
||||
order.status,
|
||||
String(order.amount),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(dashboardQuery);
|
||||
});
|
||||
const totalRevenue = filteredOrders.reduce((sum, item) => sum + item.amount, 0);
|
||||
const totalOrders = filteredOrders.length;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSearchResults() {
|
||||
if (!dashboardQuery) {
|
||||
setProductResults([]);
|
||||
setWarehouseResults([]);
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchLoading(true);
|
||||
|
||||
try {
|
||||
const [productsRes, warehousesRes] = await Promise.all([
|
||||
fetch("/api/products?page=1&size=100", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
}),
|
||||
fetch("/api/products/warehouses?page=1&size=100", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
}),
|
||||
]);
|
||||
|
||||
const [productsResult, warehousesResult] = await Promise.all([
|
||||
productsRes.json().catch(() => ({})),
|
||||
warehousesRes.json().catch(() => ({})),
|
||||
]);
|
||||
|
||||
const products: ProductSearchRow[] = Array.isArray(productsResult?.rows)
|
||||
? productsResult.rows
|
||||
: Array.isArray(productsResult?.data?.rows)
|
||||
? productsResult.data.rows
|
||||
: [];
|
||||
|
||||
const warehouses: WarehouseSearchRow[] = Array.isArray(warehousesResult?.rows)
|
||||
? warehousesResult.rows
|
||||
: Array.isArray(warehousesResult?.data)
|
||||
? warehousesResult.data
|
||||
: [];
|
||||
|
||||
setProductResults(
|
||||
products.filter((item) =>
|
||||
[item.name, item.market, item.state, item.status, String(item.totalStock ?? "")]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(dashboardQuery)
|
||||
)
|
||||
);
|
||||
|
||||
setWarehouseResults(
|
||||
warehouses.filter((item) =>
|
||||
[
|
||||
item.name || "",
|
||||
item.address || "",
|
||||
item.city || "",
|
||||
item.province || "",
|
||||
item.country || "",
|
||||
item.warehouseType || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(dashboardQuery)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
setProductResults([]);
|
||||
setWarehouseResults([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSearchResults();
|
||||
}, [dashboardQuery]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
@ -96,6 +213,101 @@ export default function DashboardPage() {
|
||||
<p className="text-on-surface-variant font-medium">{d.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{dashboardQuery ? (
|
||||
<div className="mb-10 rounded-2xl border border-surface-container bg-surface-container-lowest p-8 magazine-shadow">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.22em] text-primary">
|
||||
Dashboard Search
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-black tracking-tight text-on-surface">
|
||||
Hasil pencarian untuk "{rawDashboardQuery}"
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-on-surface-variant">
|
||||
{searchLoading
|
||||
? "Mencari data..."
|
||||
: `${filteredOrders.length} order, ${productResults.length} produk, ${warehouseResults.length} warehouse`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-2xl bg-surface p-5">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
||||
Orders
|
||||
</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filteredOrders.slice(0, 4).map((order) => (
|
||||
<div key={order.id} className="rounded-xl border border-surface-container p-4">
|
||||
<p className="font-bold text-on-surface">{order.product}</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">{order.customer} · {order.location}</p>
|
||||
</div>
|
||||
))}
|
||||
{!searchLoading && filteredOrders.length === 0 ? (
|
||||
<p className="text-sm font-medium text-on-surface-variant">Tidak ada order yang cocok.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
||||
Products
|
||||
</h3>
|
||||
<a href="/products" className="text-xs font-bold text-primary hover:underline">
|
||||
Lihat semua
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{productResults.slice(0, 4).map((product) => (
|
||||
<a
|
||||
key={product.id}
|
||||
href="/products"
|
||||
className="block rounded-xl border border-surface-container p-4 transition-colors hover:bg-surface-container-low"
|
||||
>
|
||||
<p className="font-bold text-on-surface">{product.name}</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">
|
||||
{product.market || product.state || product.status || "Produk"}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
{!searchLoading && productResults.length === 0 ? (
|
||||
<p className="text-sm font-medium text-on-surface-variant">Tidak ada produk yang cocok.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-outline">
|
||||
Warehouses
|
||||
</h3>
|
||||
<a href="/dashboard/warehouse" className="text-xs font-bold text-primary hover:underline">
|
||||
Lihat semua
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{warehouseResults.slice(0, 4).map((warehouse) => (
|
||||
<a
|
||||
key={warehouse.id}
|
||||
href="/dashboard/warehouse"
|
||||
className="block rounded-xl border border-surface-container p-4 transition-colors hover:bg-surface-container-low"
|
||||
>
|
||||
<p className="font-bold text-on-surface">{warehouse.name || "Tanpa nama"}</p>
|
||||
<p className="mt-1 text-sm text-on-surface-variant">
|
||||
{[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || warehouse.address || "Warehouse"}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
{!searchLoading && warehouseResults.length === 0 ? (
|
||||
<p className="text-sm font-medium text-on-surface-variant">Tidak ada warehouse yang cocok.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10">
|
||||
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-primary"></div>
|
||||
@ -220,7 +432,14 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="bg-surface-container-lowest rounded-xl magazine-shadow overflow-hidden">
|
||||
<div className="p-8 flex items-center justify-between border-b border-surface-container">
|
||||
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
|
||||
<div>
|
||||
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
|
||||
{dashboardQuery ? (
|
||||
<p className="mt-2 text-sm font-medium text-on-surface-variant">
|
||||
Menampilkan hasil pencarian untuk <span className="font-bold text-on-surface">"{searchParams.get("q")}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button className="text-primary font-bold text-sm hover:underline flex items-center gap-1">
|
||||
{d.viewAll}
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
@ -239,7 +458,7 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-container">
|
||||
{(data?.recentOrders || []).map((order) => (
|
||||
{filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors">
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
@ -270,10 +489,10 @@ export default function DashboardPage() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && !error && (data?.recentOrders || []).length === 0 ? (
|
||||
{!loading && !error && filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-8 py-10 text-center text-sm font-semibold text-on-surface-variant">
|
||||
No recent orders available.
|
||||
{dashboardQuery ? "Tidak ada order yang cocok dengan pencarian Anda." : "No recent orders available."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
@ -290,3 +509,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@ -43,6 +44,13 @@ export default function DashboardLayout({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const currentQuery = new URLSearchParams(window.location.search).get("q") || "";
|
||||
setSearchQuery(currentQuery);
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
@ -52,29 +60,58 @@ export default function DashboardLayout({
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextQuery = searchQuery.trim();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (nextQuery) {
|
||||
searchParams.set("q", nextQuery);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
router.push(queryString ? `/dashboard?${queryString}` : "/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
{/* Top Nav */}
|
||||
<header className="fixed top-0 w-full z-50 flex justify-between items-center px-6 h-16 bg-white/85 backdrop-blur-md shadow-sm border-b border-surface-container">
|
||||
<div className="flex items-center gap-8">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={120} height={32} priority />
|
||||
<div className="hidden md:flex items-center bg-surface-container-low px-4 py-2 rounded-xl gap-2">
|
||||
<form
|
||||
onSubmit={handleSearchSubmit}
|
||||
className="hidden md:flex items-center bg-surface-container-low px-4 py-2 rounded-xl gap-2"
|
||||
>
|
||||
<AppIcon name="search" className="h-5 w-5 text-on-surface-variant" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
className="bg-transparent border-none outline-none text-sm w-64 text-on-surface placeholder:text-on-surface-variant"
|
||||
placeholder={t.dashboard.layout.searchPlaceholder}
|
||||
type="text"
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<button className="text-on-surface-variant hover:text-primary transition-colors p-2 rounded-full relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
className="text-outline/70 p-2 rounded-full relative cursor-not-allowed opacity-70"
|
||||
>
|
||||
<AppIcon name="notifications" className="h-5 w-5" />
|
||||
<span className="absolute top-2.5 right-2.5 w-2 h-2 bg-primary rounded-full"></span>
|
||||
</button>
|
||||
<button className="text-on-surface-variant hover:text-primary transition-colors p-2 rounded-full">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
className="text-outline/70 p-2 rounded-full cursor-not-allowed opacity-70"
|
||||
>
|
||||
<AppIcon name="chat" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
@ -37,6 +38,20 @@ interface ProductMeasurement {
|
||||
}
|
||||
|
||||
interface ProductModel {
|
||||
name?: string;
|
||||
sku?: string;
|
||||
imageId?: string;
|
||||
price?: string | number;
|
||||
currency?: string;
|
||||
weight?: string | number;
|
||||
weightType?: string;
|
||||
length?: string | number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
dimensionType?: string;
|
||||
isConfigurePromotionPrice?: boolean;
|
||||
promotionPrice?: string | number;
|
||||
promotionCurrency?: string;
|
||||
warehouses?: ProductWarehouse[];
|
||||
productMeasurements?: ProductMeasurement[];
|
||||
}
|
||||
@ -440,100 +455,14 @@ function ProductDetailPageInner() {
|
||||
|
||||
{/* ── Section 03: Pricing & Model ───────────────────────────────────── */}
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white text-xs font-black shadow-md shadow-primary/20">03</div>
|
||||
<h2 className="text-xl font-black font-headline tracking-tight text-on-surface">{d.section03} ({models.length})</h2>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{models.map((m: any, i: number) => {
|
||||
const weightUnit = m.weightType || "G";
|
||||
const dimUnit = m.dimensionType || "CM";
|
||||
const pkgWeightUnit = m.packagingWeightType || "G";
|
||||
const pkgDimUnit = m.packagingDimensionType || "CM";
|
||||
const measurements = Array.isArray(m.productMeasurements) ? m.productMeasurements : [];
|
||||
return (
|
||||
<div key={i} className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-surface-container">
|
||||
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center text-white text-xs font-black">{i + 1}</div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-on-surface">{m.name || `Model ${i + 1}`}</h3>
|
||||
{m.sku && <span className="text-[10px] text-outline font-bold ml-2">SKU: {m.sku}</span>}
|
||||
{measurements.length > 0 && (
|
||||
<span className="ml-auto text-[10px] font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">{measurements.length} measurement(s)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<Row label={d.price} value={m.price ? `${m.currency || "IDR"} ${Number(m.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label={`${d.price.includes("H") ? "Berat" : "Weight"} (${weightUnit})`} value={m.weight} />
|
||||
<Row label={`Dim (${dimUnit})`} value={[m.length, m.width, m.height].filter(Boolean).join(" × ") || undefined} />
|
||||
{m.isConfigurePromotionPrice && <Row label="Promo" value={m.promotionPrice ? `${m.promotionCurrency || m.currency || "IDR"} ${Number(m.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
{m.isConfigurePromotionPrice && m.promotionStartDate && (
|
||||
<Row label="Promo Period" value={`${m.promotionStartDate} → ${m.promotionEndDate}`} />
|
||||
)}
|
||||
<Row label={`Pkg Weight (${pkgWeightUnit})`} value={m.packagingWeight} />
|
||||
<Row label={`Pkg Dim (${pkgDimUnit})`} value={[m.packagingLength, m.packagingWidth, m.packagingHeight].filter(Boolean).join(" × ") || undefined} />
|
||||
</div>
|
||||
{/* Warehouses */}
|
||||
{Array.isArray(m.warehouses) && m.warehouses.length > 0 && (
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.warehouseStock}</p>
|
||||
<div className="space-y-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-1 border-b border-surface-container last:border-0">
|
||||
<span>{warehouseMap[w.id] || w.name || `${w.id?.slice(0, 8)}...`}</span>
|
||||
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Measurements */}
|
||||
{measurements.length > 0 && (
|
||||
<div className="px-6 pb-6 border-t border-surface-container mt-2">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mt-4 mb-3">Measurements / Variants</p>
|
||||
<div className="space-y-3">
|
||||
{measurements.map((ms: ProductMeasurement, mi: number) => {
|
||||
const msWeightUnit = ms.weightType || "G";
|
||||
const msDimUnit = ms.dimensionType || "CM";
|
||||
return (
|
||||
<div key={mi} className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-outline bg-surface-container px-2.5 py-1 rounded-full">
|
||||
{String(mi + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{ms.measurementType && <span className="text-xs font-bold text-on-surface">{ms.measurementType}</span>}
|
||||
{ms.measurementValue && <span className="text-xs text-on-surface-variant">— {ms.measurementValue}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<Row label="Harga" value={ms.price ? `${ms.currency || "IDR"} ${Number(ms.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label={`Berat (${msWeightUnit})`} value={ms.weight} />
|
||||
<Row label={`Dimensi (${msDimUnit})`} value={[ms.length, ms.width, ms.height].filter(Boolean).join(" × ") || undefined} />
|
||||
{ms.isConfigurePromotionPrice && <Row label="Harga Promo" value={ms.promotionPrice ? `${ms.promotionCurrency || ms.currency || "IDR"} ${Number(ms.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
</div>
|
||||
{Array.isArray(ms.warehouses) &&
|
||||
ms.warehouses.filter((w: ProductWarehouse) => w.id).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-outline-variant/10">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-outline mb-1.5">Stock</p>
|
||||
{ms.warehouses
|
||||
.filter((w: ProductWarehouse) => w.id)
|
||||
.map((w: ProductWarehouse, wi: number) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
|
||||
<span>{warehouseMap[w.id || ""] || `${w.id?.slice(0, 8)}...`}</span>
|
||||
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="03" title={`${d.section03} (${models.length})`} />
|
||||
<ProductVariantShowcase
|
||||
product={product}
|
||||
warehouseLabelResolver={(warehouse) =>
|
||||
warehouse.id ? warehouseMap[String(warehouse.id)] || String(warehouse.id) : warehouse.name || "-"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { getProductEffectivePoints } from "@/lib/product-variants";
|
||||
|
||||
type TabLabel =
|
||||
| "All Product"
|
||||
@ -28,6 +29,7 @@ interface ProductRow {
|
||||
status?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
totalStock: number;
|
||||
productModels?: ProductModelRef[];
|
||||
}
|
||||
|
||||
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
|
||||
@ -103,17 +105,89 @@ function getToken() {
|
||||
}
|
||||
|
||||
function formatPrice(product: ProductRow) {
|
||||
if (!product.minPrice && !product.maxPrice) {
|
||||
return "-";
|
||||
const minPrice = Number(product.minPrice);
|
||||
const maxPrice = Number(product.maxPrice);
|
||||
const hasApiRange = Number.isFinite(minPrice) && Number.isFinite(maxPrice) && (minPrice > 0 || maxPrice > 0);
|
||||
|
||||
if (hasApiRange) {
|
||||
const formatter = new Intl.NumberFormat("id-ID");
|
||||
if (minPrice === maxPrice) {
|
||||
return `Rp ${formatter.format(minPrice)}`;
|
||||
}
|
||||
return `Rp ${formatter.format(minPrice)} - ${formatter.format(maxPrice)}`;
|
||||
}
|
||||
|
||||
const models = Array.isArray(product.productModels) ? product.productModels : [];
|
||||
const prices = getProductEffectivePoints(models)
|
||||
.map((point) => point.price)
|
||||
.filter((value): value is number => value !== undefined && value > 0)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (prices.length === 0) return "-";
|
||||
|
||||
const formatter = new Intl.NumberFormat("id-ID");
|
||||
|
||||
if (product.minPrice === product.maxPrice) {
|
||||
return `Rp ${formatter.format(product.minPrice)}`;
|
||||
if (prices[0] === prices[prices.length - 1]) {
|
||||
return `Rp ${formatter.format(prices[0])}`;
|
||||
}
|
||||
|
||||
return `Rp ${formatter.format(product.minPrice)} - ${formatter.format(product.maxPrice)}`;
|
||||
return `Rp ${formatter.format(prices[0])} - ${formatter.format(prices[prices.length - 1])}`;
|
||||
}
|
||||
|
||||
function hasMissingListPrice(product: ProductRow) {
|
||||
const minPrice = Number(product.minPrice);
|
||||
const maxPrice = Number(product.maxPrice);
|
||||
return (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice) || (minPrice <= 0 && maxPrice <= 0));
|
||||
}
|
||||
|
||||
async function hydrateRowsWithEffectivePrice(
|
||||
rows: ProductRow[],
|
||||
token: string,
|
||||
query: string
|
||||
) {
|
||||
const targets = rows.filter(hasMissingListPrice);
|
||||
if (targets.length === 0) return rows;
|
||||
|
||||
const details = await Promise.all(
|
||||
targets.map(async (row) => {
|
||||
try {
|
||||
const res = await fetch(`/api/products/${row.id}${query ? `?${query}` : ""}`, {
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
const result = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return [row.id, null] as const;
|
||||
const data = result?.data || result;
|
||||
return [row.id, data] as const;
|
||||
} catch {
|
||||
return [row.id, null] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const detailMap = new Map(details);
|
||||
|
||||
return rows.map((row) => {
|
||||
const detail = detailMap.get(row.id);
|
||||
if (!detail || !Array.isArray(detail.productModels)) return row;
|
||||
|
||||
const prices = getProductEffectivePoints(detail.productModels)
|
||||
.map((point) => point.price)
|
||||
.filter((value): value is number => value !== undefined && value > 0)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (prices.length === 0) {
|
||||
return {
|
||||
...row,
|
||||
productModels: detail.productModels,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
minPrice: prices[0],
|
||||
maxPrice: prices[prices.length - 1],
|
||||
productModels: detail.productModels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function marketClasses(market: string) {
|
||||
@ -745,9 +819,10 @@ function ProductsPageInner() {
|
||||
if (tab) params.set("tab", tab);
|
||||
params.set("page", String(page));
|
||||
params.set("size", "20");
|
||||
const token = getToken();
|
||||
|
||||
const res = await fetch(`/api/products?${params.toString()}`,
|
||||
{ headers: { "x-auth-token": getToken() } }
|
||||
{ headers: { "x-auth-token": token } }
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
@ -755,7 +830,13 @@ function ProductsPageInner() {
|
||||
throw new Error(result?.responseDesc || "Failed to load products");
|
||||
}
|
||||
|
||||
setRows(result?.rows || result?.data?.rows || []);
|
||||
const nextRows = result?.rows || result?.data?.rows || [];
|
||||
const requestParams = new URLSearchParams();
|
||||
if (tab === "draft") requestParams.set("draft", "1");
|
||||
if (tab === "in-review") requestParams.set("review", "1");
|
||||
const hydratedRows = await hydrateRowsWithEffectivePrice(nextRows, token, requestParams.toString());
|
||||
|
||||
setRows(hydratedRows);
|
||||
setTotalItem(result?.totalItem || result?.data?.totalItem || 0);
|
||||
setTotalPage(result?.totalPage || result?.data?.totalPage || 0);
|
||||
} catch (err) {
|
||||
|
||||
@ -2,15 +2,13 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const steps = [
|
||||
{ href: "/onboarding/business", icon: "storefront", labelKey: "business" as const, step: 1 },
|
||||
{ href: "/onboarding/store-detail", icon: "store", labelKey: "storeDetail" as const, step: 2 },
|
||||
{ href: "/onboarding/plan", icon: "payments", labelKey: "plan" as const, step: 3 },
|
||||
];
|
||||
|
||||
export default function OnboardingLayout({
|
||||
@ -19,7 +17,6 @@ export default function OnboardingLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const lo = t.onboarding.layout;
|
||||
|
||||
@ -27,16 +24,6 @@ export default function OnboardingLayout({
|
||||
const currentStep = steps.find((s) => pathname.startsWith(s.href));
|
||||
const stepNumber = currentStep?.step ?? (isSuccessPage ? steps.length : 1);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/onboarding")) {
|
||||
router.replace("/dashboard");
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
if (pathname.startsWith("/onboarding")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSuccessPage) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-on-surface font-body antialiased">
|
||||
|
||||
@ -1,371 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
type PlanId = "starter" | "professional" | "organization" | "enterprise";
|
||||
|
||||
interface PlanOption {
|
||||
id: PlanId;
|
||||
borderClass: string;
|
||||
actionClass: string;
|
||||
badge?: boolean;
|
||||
featureKeys: string[];
|
||||
price: string;
|
||||
unit: string;
|
||||
seats: string;
|
||||
ctaKey: "currentStep" | "selectPlan" | "contactUs";
|
||||
}
|
||||
|
||||
const PLANS: PlanOption[] = [
|
||||
{
|
||||
id: "starter",
|
||||
price: "Free",
|
||||
unit: "/mo",
|
||||
seats: "1 Seat",
|
||||
borderClass: "border-slate-200",
|
||||
actionClass: "bg-surface-container-high text-on-surface hover:bg-surface-dim",
|
||||
ctaKey: "currentStep",
|
||||
featureKeys: ["basicAnalytics", "upTo10Trades", "apiAccess"],
|
||||
},
|
||||
{
|
||||
id: "professional",
|
||||
price: "$29",
|
||||
unit: "/mo",
|
||||
seats: "5 Seats",
|
||||
borderClass: "border-primary ring-2 ring-primary/20 shadow-2xl shadow-primary/10",
|
||||
actionClass: "bg-gradient-to-br from-primary to-primary-container text-on-primary shadow-md shadow-primary/20 hover:opacity-95",
|
||||
badge: true,
|
||||
ctaKey: "selectPlan",
|
||||
featureKeys: ["advancedReports", "unlimitedTrades", "exportToExcel"],
|
||||
},
|
||||
{
|
||||
id: "organization",
|
||||
price: "$99",
|
||||
unit: "/mo",
|
||||
seats: "20 Seats",
|
||||
borderClass: "border-tertiary/40",
|
||||
actionClass: "bg-secondary text-on-secondary hover:opacity-90",
|
||||
ctaKey: "selectPlan",
|
||||
featureKeys: ["fullApiAccess", "customDashboards", "teamManagement"],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
price: "Talk",
|
||||
unit: "/sales",
|
||||
seats: "Unlimited",
|
||||
borderClass: "border-inverse-surface/30",
|
||||
actionClass: "border-2 border-on-surface bg-transparent text-on-surface hover:bg-on-surface hover:text-white",
|
||||
ctaKey: "contactUs",
|
||||
featureKeys: ["slaGuarantee", "dedicatedManager", "customIntegrations"],
|
||||
},
|
||||
];
|
||||
|
||||
const PLAN_NAMES: Record<PlanId, string> = {
|
||||
starter: "Starter",
|
||||
professional: "Professional",
|
||||
organization: "Organization",
|
||||
enterprise: "Enterprise",
|
||||
};
|
||||
|
||||
const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft";
|
||||
const ONBOARDING_STORE_STORAGE_KEY = "onboardingStoreDetailDraft";
|
||||
|
||||
// Which features are included per plan (index 0,1,2 correspond to featureKeys)
|
||||
const PLAN_INCLUDED: Record<PlanId, [boolean, boolean, boolean]> = {
|
||||
starter: [true, true, false],
|
||||
professional: [true, true, true],
|
||||
organization: [true, true, true],
|
||||
enterprise: [true, true, true],
|
||||
};
|
||||
|
||||
function featureIconClass(planId: PlanId) {
|
||||
if (planId === "organization") return "text-tertiary";
|
||||
if (planId === "enterprise") return "text-on-surface";
|
||||
return "text-primary";
|
||||
}
|
||||
|
||||
export default function PlanPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const p = t.onboarding.plan;
|
||||
const common = t.common;
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanId>(() => {
|
||||
if (typeof window === "undefined") return "professional";
|
||||
const storedPlan = sessionStorage.getItem("selectedPlan") as PlanId | null;
|
||||
return storedPlan && PLANS.some((plan) => plan.id === storedPlan)
|
||||
? storedPlan
|
||||
: "professional";
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function getToken() {
|
||||
return (
|
||||
sessionStorage.getItem("token") || localStorage.getItem("token") || ""
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
const businessDraft = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
const storeDraft = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!businessDraft) {
|
||||
router.replace("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storeDraft) {
|
||||
router.replace("/onboarding/store-detail");
|
||||
}
|
||||
router.replace("/onboarding/store-detail");
|
||||
}, [router]);
|
||||
|
||||
function persistPlan(planId: PlanId) {
|
||||
setSelectedPlan(planId);
|
||||
sessionStorage.setItem("selectedPlan", planId);
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem("selectedPlan", selectedPlan);
|
||||
|
||||
const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
if (!businessDraftRaw) {
|
||||
throw new Error("Business onboarding data not found");
|
||||
}
|
||||
|
||||
const storeDraftRaw = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
if (!storeDraftRaw) {
|
||||
throw new Error("Store detail onboarding data not found");
|
||||
}
|
||||
|
||||
const businessDraft = JSON.parse(businessDraftRaw) as {
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const storeDraft = JSON.parse(storeDraftRaw) as {
|
||||
payload?: {
|
||||
store: Record<string, unknown>;
|
||||
warehouse: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
if (!businessDraft.payload) {
|
||||
throw new Error("Business onboarding payload not found");
|
||||
}
|
||||
|
||||
if (!storeDraft.payload?.store || !storeDraft.payload?.warehouse) {
|
||||
throw new Error("Store detail onboarding payload not found");
|
||||
}
|
||||
|
||||
const sellerRes = await fetch("/api/seller", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(businessDraft.payload),
|
||||
});
|
||||
|
||||
const sellerData = await sellerRes.json().catch(() => ({}));
|
||||
if (!sellerRes.ok) {
|
||||
throw new Error(
|
||||
sellerData?.error || sellerData?.responseDesc || common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
const storeRes = await fetch("/api/seller/store", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(storeDraft.payload.store),
|
||||
});
|
||||
|
||||
const storeData = await storeRes.json().catch(() => ({}));
|
||||
if (!storeRes.ok) {
|
||||
throw new Error(
|
||||
storeData?.error || storeData?.responseDesc || common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
const warehouseRes = await fetch("/api/warehouses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(storeDraft.payload.warehouse),
|
||||
});
|
||||
|
||||
const warehouseData = await warehouseRes.json().catch(() => ({}));
|
||||
if (!warehouseRes.ok) {
|
||||
throw new Error(
|
||||
warehouseData?.error ||
|
||||
warehouseData?.responseDesc ||
|
||||
common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
router.push("/onboarding/success");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : common.connectionError
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-surface px-6 pb-36 pt-24 text-on-surface">
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-8">
|
||||
<div className="flex-1">
|
||||
<header className="mb-12">
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-xl bg-error-container px-4 py-3 text-sm font-medium text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary">
|
||||
{p.step}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mb-4 max-w-4xl font-headline text-5xl font-extrabold leading-tight tracking-tight text-on-surface">
|
||||
{p.title}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-xl leading-relaxed text-on-surface-variant">
|
||||
{p.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{PLANS.map((plan) => {
|
||||
const isSelected = selectedPlan === plan.id;
|
||||
const included = PLAN_INCLUDED[plan.id];
|
||||
|
||||
return (
|
||||
<article
|
||||
key={plan.id}
|
||||
className={`relative flex min-h-[430px] flex-col rounded-xl border-l-4 bg-surface-container-lowest p-8 transition-all duration-200 hover:-translate-y-1 ${plan.borderClass} ${
|
||||
isSelected ? "scale-[1.01]" : ""
|
||||
}`}
|
||||
>
|
||||
{plan.badge ? (
|
||||
<div className="absolute -top-4 left-6 rounded-full bg-primary px-3 py-1 text-[10px] font-black uppercase tracking-tight text-white shadow-lg">
|
||||
{p.mostPopular}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h3
|
||||
className={`mb-1 text-lg font-bold ${
|
||||
plan.id === "professional"
|
||||
? "text-primary"
|
||||
: plan.id === "organization"
|
||||
? "text-tertiary"
|
||||
: "text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{PLAN_NAMES[plan.id]}
|
||||
</h3>
|
||||
|
||||
<div className="mb-6 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-black text-on-surface">{plan.price}</span>
|
||||
<span className="text-sm text-on-surface-variant">{plan.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 space-y-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-on-surface">
|
||||
<span className="material-symbols-outlined text-sm">group</span>
|
||||
{plan.seats}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{plan.featureKeys.map((key, idx) => {
|
||||
const featureLabel = p.features[key as keyof typeof p.features];
|
||||
const isIncluded = included[idx];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex gap-2 text-sm ${
|
||||
isIncluded ? "text-on-surface-variant" : "text-on-surface-variant opacity-40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-sm ${
|
||||
isIncluded ? featureIconClass(plan.id) : "text-on-surface-variant"
|
||||
}`}
|
||||
>
|
||||
{isIncluded ? "check" : "close"}
|
||||
</span>
|
||||
<span>{featureLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistPlan(plan.id)}
|
||||
className={`mt-auto w-full rounded-xl px-4 py-4 font-bold transition-all active:scale-95 ${plan.actionClass}`}
|
||||
>
|
||||
{isSelected ? p.selected : p[plan.ctaKey]}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="fixed bottom-0 left-0 right-0 z-40 bg-surface-container-lowest px-6 py-6 shadow-[0_-10px_30px_rgba(25,28,29,0.04)]">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/onboarding/store-detail")}
|
||||
className="flex items-center gap-2 rounded-xl px-6 py-2 font-bold text-on-surface transition-colors hover:bg-surface-container"
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
{p.back}
|
||||
</button>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={submitting}
|
||||
className="px-4 font-medium text-on-surface-variant disabled:opacity-70"
|
||||
>
|
||||
{p.skip}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-xl bg-primary px-10 py-4 font-bold text-on-primary shadow-xl shadow-primary/20 transition-all hover:scale-105 active:scale-95 disabled:opacity-70 disabled:hover:scale-100"
|
||||
>
|
||||
{submitting ? `${p.submitSelection}...` : p.submitSelection}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -17,8 +17,6 @@ const headlineFieldClass =
|
||||
const textareaClass =
|
||||
"w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-3.5 text-sm leading-relaxed text-on-surface shadow-sm transition-all focus:border-primary focus:bg-surface-container-lowest focus:outline-none";
|
||||
|
||||
type WarehouseType = "INA" | "OTHER";
|
||||
|
||||
function toNumber(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return 0;
|
||||
@ -26,10 +24,21 @@ function toNumber(value: string) {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
type WarehouseListRow = {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
postalCode?: string | null;
|
||||
warehouseType?: string | null;
|
||||
};
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const sd = t.onboarding.storeDetail;
|
||||
const common = t.common;
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@ -42,7 +51,7 @@ export default function StoreDetailPage() {
|
||||
const [warehouse, setWarehouse] = useState({
|
||||
name: "",
|
||||
address: "",
|
||||
warehouseType: "INA" as WarehouseType,
|
||||
warehouseType: "INA",
|
||||
country: "Indonesia",
|
||||
province: "",
|
||||
city: "",
|
||||
@ -115,7 +124,7 @@ export default function StoreDetailPage() {
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
@ -158,6 +167,22 @@ export default function StoreDetailPage() {
|
||||
},
|
||||
};
|
||||
|
||||
const businessDraftRaw = sessionStorage.getItem(
|
||||
ONBOARDING_BUSINESS_STORAGE_KEY
|
||||
);
|
||||
|
||||
if (!businessDraftRaw) {
|
||||
throw new Error(sd.genericError);
|
||||
}
|
||||
|
||||
const businessDraft = JSON.parse(businessDraftRaw) as {
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!businessDraft.payload) {
|
||||
throw new Error(sd.genericError);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(
|
||||
ONBOARDING_STORE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@ -167,9 +192,101 @@ export default function StoreDetailPage() {
|
||||
})
|
||||
);
|
||||
|
||||
router.push("/onboarding/plan");
|
||||
const sellerRes = await fetch("/api/seller", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(businessDraft.payload),
|
||||
});
|
||||
|
||||
const sellerData = await sellerRes.json().catch(() => ({}));
|
||||
if (!sellerRes.ok) {
|
||||
throw new Error(
|
||||
sellerData?.error || sellerData?.responseDesc || common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
const storeRes = await fetch("/api/seller/store", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(payload.store),
|
||||
});
|
||||
|
||||
const storeData = await storeRes.json().catch(() => ({}));
|
||||
if (!storeRes.ok) {
|
||||
throw new Error(
|
||||
storeData?.error || storeData?.responseDesc || common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
const warehouseListRes = await fetch("/api/products/warehouses?page=1&size=100", {
|
||||
headers: {
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const warehouseListData = await warehouseListRes.json().catch(() => ({}));
|
||||
const warehouseRows: WarehouseListRow[] = Array.isArray(warehouseListData?.rows)
|
||||
? warehouseListData.rows
|
||||
: [];
|
||||
|
||||
const normalizedWarehouseAddress = payload.warehouse.address.trim().toLowerCase();
|
||||
const normalizedWarehouseCity = payload.warehouse.city.trim().toLowerCase();
|
||||
const normalizedWarehouseProvince = payload.warehouse.province.trim().toLowerCase();
|
||||
const normalizedWarehousePostalCode = payload.warehouse.postalCode.trim().toLowerCase();
|
||||
|
||||
const autogeneratedWarehouse = warehouseRows.find((item) => {
|
||||
const normalizedItemAddress = (item.address || "").trim().toLowerCase();
|
||||
const normalizedItemCity = (item.city || "").trim().toLowerCase();
|
||||
const normalizedItemProvince = (item.province || "").trim().toLowerCase();
|
||||
const normalizedItemPostalCode = (item.postalCode || "").trim().toLowerCase();
|
||||
const hasNoConfiguredName = !item.name || !item.name.trim();
|
||||
const hasNoConfiguredType = !item.warehouseType || !item.warehouseType.trim();
|
||||
const sameAddress =
|
||||
normalizedItemAddress === normalizedWarehouseAddress &&
|
||||
normalizedItemCity === normalizedWarehouseCity &&
|
||||
normalizedItemProvince === normalizedWarehouseProvince &&
|
||||
normalizedItemPostalCode === normalizedWarehousePostalCode;
|
||||
|
||||
return Boolean(item.id) && (sameAddress || hasNoConfiguredName || hasNoConfiguredType);
|
||||
});
|
||||
|
||||
const warehouseEndpoint = autogeneratedWarehouse?.id
|
||||
? `/api/warehouses/${autogeneratedWarehouse.id}`
|
||||
: "/api/warehouses";
|
||||
const warehouseMethod = autogeneratedWarehouse?.id ? "PUT" : "POST";
|
||||
|
||||
const warehouseRes = await fetch(warehouseEndpoint, {
|
||||
method: warehouseMethod,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(payload.warehouse),
|
||||
});
|
||||
|
||||
const warehouseData = await warehouseRes.json().catch(() => ({}));
|
||||
if (!warehouseRes.ok) {
|
||||
throw new Error(
|
||||
warehouseData?.error ||
|
||||
warehouseData?.responseDesc ||
|
||||
common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
sessionStorage.removeItem("selectedPlan");
|
||||
router.push("/onboarding/success");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : sd.genericError);
|
||||
setError(
|
||||
err instanceof Error ? err.message : common.connectionError
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -299,25 +416,6 @@ export default function StoreDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.warehouseType}
|
||||
</label>
|
||||
<select
|
||||
value={warehouse.warehouseType}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({
|
||||
...prev,
|
||||
warehouseType: e.target.value as WarehouseType,
|
||||
}))
|
||||
}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="INA">INA</option>
|
||||
<option value="OTHER">{sd.other}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.country}
|
||||
|
||||
@ -40,9 +40,8 @@ export default function SuccessPage() {
|
||||
<p className="text-on-surface-variant leading-relaxed mb-8 max-w-sm">
|
||||
Profil UMKM institusional Anda telah tercatat dan sedang ditinjau
|
||||
oleh tim verifikasi kami. Setelah disetujui, Anda akan mendapat akses
|
||||
penuh ke protokol{" "}
|
||||
<span className="text-tertiary font-semibold">Trading Desk Alpha</span>.
|
||||
Lanjutkan ke dashboard untuk memantau status Anda.
|
||||
penuh ke Dashboard seller Ina Tarading. Lanjutkan ke dashboard
|
||||
untuk memantau status Anda.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
@ -459,85 +460,21 @@ export default function AdminProductDetailPage() {
|
||||
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="05" title="Models and Pricing" />
|
||||
{models.length ? (
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => (
|
||||
<div key={`${model.sku || model.name || index}`} className="rounded-2xl border border-surface-container p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-black text-on-surface">{model.name || `Model ${index + 1}`}</p>
|
||||
<p className="mt-1 text-xs text-outline">SKU: {model.sku || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(Array.isArray(model.productMeasurements) ? model.productMeasurements : []).map((measurement, measurementIndex) => (
|
||||
<div key={`${measurement.measurementType || measurementIndex}`} className="rounded-xl bg-surface-container-low p-4">
|
||||
<Row
|
||||
label="Measurement"
|
||||
value={[
|
||||
measurement.measurementType,
|
||||
measurement.measurementValue,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - ") || "—"}
|
||||
/>
|
||||
<Row
|
||||
label="Price"
|
||||
value={
|
||||
measurement.price != null
|
||||
? `${measurement.currency || "IDR"} ${Number(measurement.price).toLocaleString("id-ID")}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Weight"
|
||||
value={
|
||||
measurement.weight != null
|
||||
? `${measurement.weight} ${measurement.weightType || ""}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Dimension"
|
||||
value={
|
||||
[measurement.length, measurement.width, measurement.height].some(Boolean)
|
||||
? `${[measurement.length, measurement.width, measurement.height].filter(Boolean).join(" x ")} ${measurement.dimensionType || ""}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{measurement.isConfigurePromotionPrice ? (
|
||||
<Row
|
||||
label="Promotion Price"
|
||||
value={
|
||||
measurement.promotionPrice != null
|
||||
? `${measurement.promotionCurrency || measurement.currency || "IDR"} ${Number(measurement.promotionPrice).toLocaleString("id-ID")}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{Array.isArray(measurement.warehouses) && measurement.warehouses.length ? (
|
||||
<div className="mt-3 border-t border-surface-container pt-3">
|
||||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Warehouses</p>
|
||||
{measurement.warehouses.map((warehouse, warehouseIndex) => (
|
||||
<div key={`${warehouse.id || warehouseIndex}`} className="flex justify-between py-1 text-sm">
|
||||
<span className="text-on-surface-variant">
|
||||
{[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || "-"}
|
||||
</span>
|
||||
<span className="font-bold text-on-surface">{warehouse.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-surface-container-low p-4 text-sm font-semibold text-outline">Tidak ada model produk</div>
|
||||
)}
|
||||
<ProductVariantShowcase
|
||||
product={product}
|
||||
warehouseLabelResolver={(warehouse) =>
|
||||
[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
|
||||
}
|
||||
labels={{
|
||||
summaryTitle: "Variant Summary",
|
||||
structureTitle: "Variant Structure",
|
||||
productPrice: "Product Price",
|
||||
productWeight: "Product Weight",
|
||||
productDimension: "Product Dimension",
|
||||
selectedDetailTitle: "Selected Variant Detail",
|
||||
stock: "Warehouses",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ProductVariantShowcase } from "@/components/product-variant-showcase";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
@ -50,7 +51,7 @@ function formatMeasurementLabel(
|
||||
index: number
|
||||
) {
|
||||
const parts = [measurement.measurementType, measurement.measurementValue].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(" - ") : `Measurement ${index + 1}`;
|
||||
return parts.length > 0 ? parts.join(" - ") : `Ukuran ${index + 1}`;
|
||||
}
|
||||
|
||||
interface ReviewWarehouse {
|
||||
@ -86,6 +87,11 @@ interface ReviewModel extends ReviewMeasurement {
|
||||
productMeasurements?: ReviewMeasurement[];
|
||||
}
|
||||
|
||||
interface ReviewInfoItem {
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
}
|
||||
|
||||
interface ReviewProductData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@ -100,6 +106,8 @@ interface ReviewProductData {
|
||||
productModels?: ReviewModel[];
|
||||
productKeyWords?: string[];
|
||||
productFeatures?: string[];
|
||||
productInformations?: ReviewInfoItem[];
|
||||
categoryInformations?: ReviewInfoItem[];
|
||||
subCategory?: {
|
||||
name?: string;
|
||||
category?: {
|
||||
@ -162,39 +170,43 @@ function ModelCard({
|
||||
const modelDimension = formatDimension(model.length, model.width, model.height, model.dimensionType);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${changed ? "border-amber-300 bg-amber-50/70" : accent ? "border-primary/20 bg-white" : "border-slate-100 bg-slate-50"}`}>
|
||||
<div className={`rounded-2xl border p-5 ${changed ? "border-amber-300 bg-amber-50/70" : "border-surface-container bg-surface-container-low"}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{imgUrl(model.imageId) && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imgUrl(model.imageId)!} alt={model.name} className="w-12 h-12 object-cover rounded-lg border border-slate-100" />
|
||||
<img src={imgUrl(model.imageId)!} alt={model.name} className="h-12 w-12 object-cover rounded-xl border border-surface-container" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-black text-sm text-on-surface">{model.name || `Model ${index + 1}`}</p>
|
||||
{changed && (
|
||||
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-amber-700">
|
||||
Updated
|
||||
Diperbarui
|
||||
</p>
|
||||
)}
|
||||
{hasMeasurements && (
|
||||
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-primary">
|
||||
{measurements.length} measurement variation(s)
|
||||
{measurements.length} variasi ukuran
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Row label="SKU" value={model.sku} />
|
||||
<Row label="Harga" value={hasMeasurements ? "Menggunakan harga measurement" : modelPrice} />
|
||||
<Row label="Berat" value={hasMeasurements ? "Menggunakan berat measurement" : modelWeight} />
|
||||
<Row label="Dimensi" value={hasMeasurements ? "Menggunakan dimensi measurement" : modelDimension} />
|
||||
{model.isConfigurePromotionPrice && !hasMeasurements && (
|
||||
<Row label="Harga Promo" value={modelPromotionPrice} />
|
||||
)}
|
||||
{!hasMeasurements ? (
|
||||
<>
|
||||
<Row label="Harga" value={modelPrice} />
|
||||
<Row label="Berat" value={modelWeight} />
|
||||
<Row label="Dimensi" value={modelDimension} />
|
||||
{model.isConfigurePromotionPrice && (
|
||||
<Row label="Harga Promo" value={modelPromotionPrice} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
|
||||
<div className="mt-3 pt-3 border-t border-surface-container">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">Warehouse & Stok</p>
|
||||
{model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
|
||||
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1">
|
||||
<span className="text-slate-500">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
|
||||
<span className="text-on-surface-variant">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
|
||||
<span className="font-black">{warehouse.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
@ -202,7 +214,7 @@ function ModelCard({
|
||||
)}
|
||||
|
||||
{hasMeasurements && (
|
||||
<div className="mt-4 space-y-3 border-t border-slate-100 pt-4">
|
||||
<div className="mt-4 space-y-3 border-t border-surface-container pt-4">
|
||||
{measurements.map((measurement: ReviewMeasurement, measurementIndex: number) => {
|
||||
const measurementPrice =
|
||||
formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-";
|
||||
@ -221,7 +233,7 @@ function ModelCard({
|
||||
return (
|
||||
<div
|
||||
key={`${measurement.measurementType || "measurement"}-${measurement.measurementValue || measurementIndex}`}
|
||||
className={`rounded-lg border p-4 ${accent ? "border-primary/10 bg-primary/5" : "border-slate-200 bg-white/80"}`}
|
||||
className="rounded-xl border border-surface-container bg-white p-4"
|
||||
>
|
||||
<p className="mb-3 text-xs font-black uppercase tracking-wider text-on-surface">
|
||||
{formatMeasurementLabel(measurement, measurementIndex)}
|
||||
@ -233,11 +245,11 @@ function ModelCard({
|
||||
<Row label="Harga Promo" value={measurementPromotionPrice} />
|
||||
)}
|
||||
{Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
|
||||
<div className="mt-3 pt-3 border-t border-surface-container">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">Warehouse & Stok</p>
|
||||
{measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
|
||||
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1">
|
||||
<span className="text-slate-500">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
|
||||
<span className="text-on-surface-variant">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
|
||||
<span className="font-black">{warehouse.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
@ -258,13 +270,39 @@ function Row({ label, value }: { label: string; value?: string | number | boolea
|
||||
if (value === "" || value === undefined || value === null) return null;
|
||||
const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value);
|
||||
return (
|
||||
<div className="flex justify-between gap-4 py-2.5 border-b border-slate-100 last:border-0 text-sm">
|
||||
<span className="text-slate-500 font-medium flex-shrink-0">{label}</span>
|
||||
<div className="flex justify-between gap-4 py-2 border-b border-surface-container last:border-0 text-sm">
|
||||
<span className="text-on-surface-variant font-medium flex-shrink-0">{label}</span>
|
||||
<span className="font-semibold text-on-surface text-right">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleBadge({ label, value }: { label: string; value: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-xl bg-surface-container-low p-4">
|
||||
<span className="text-sm font-bold text-on-surface">{label}</span>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-black uppercase tracking-wider ${
|
||||
value ? "bg-primary/10 text-primary" : "bg-surface-container text-outline"
|
||||
}`}
|
||||
>
|
||||
{value ? "Ya" : "Tidak"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ step, title }: { step: string; title: string }) {
|
||||
return (
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-xs font-black text-white shadow-md shadow-primary/20">
|
||||
{step}
|
||||
</div>
|
||||
<h2 className="font-headline text-xl font-black tracking-tight text-on-surface">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
accent,
|
||||
@ -277,14 +315,14 @@ function SectionCard({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border shadow-sm p-5 ${changed ? "border-amber-300 bg-amber-50/70" : accent ? "border-primary/30 bg-primary/5" : "bg-white border-slate-100"}`}>
|
||||
<div className={`mb-4 flex items-center justify-between gap-3 pb-3 border-b ${changed ? "border-amber-200" : accent ? "border-primary/20" : "border-slate-100"}`}>
|
||||
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : accent ? "text-primary" : "text-slate-400"}`}>
|
||||
<div className={`rounded-2xl border shadow-sm p-6 ${changed ? "border-amber-300 bg-amber-50/70" : "bg-surface-container-lowest border-outline-variant/10"}`}>
|
||||
<div className={`mb-4 flex items-center justify-between gap-3 pb-3 border-b ${changed ? "border-amber-200" : "border-surface-container"}`}>
|
||||
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : "text-outline"}`}>
|
||||
{title}
|
||||
</h3>
|
||||
{changed && (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-amber-800">
|
||||
Updated
|
||||
Diperbarui
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -355,6 +393,12 @@ function ProductColumn({
|
||||
const images = Array.isArray(product.productImages) ? product.productImages : [];
|
||||
const features = Array.isArray(product.productFeatures) ? product.productFeatures : [];
|
||||
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : [];
|
||||
const productInfos = Array.isArray(product.productInformations)
|
||||
? product.productInformations.filter((item) => item.paramName && item.paramValue)
|
||||
: [];
|
||||
const categoryInfos = Array.isArray(product.categoryInformations)
|
||||
? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
|
||||
: [];
|
||||
const allImages: string[] = [
|
||||
...(product.imageId ? [product.imageId] : []),
|
||||
...images
|
||||
@ -368,21 +412,19 @@ function ProductColumn({
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Column label */}
|
||||
<div className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-center ${accent ? "bg-primary text-white" : "bg-slate-100 text-slate-500"}`}>
|
||||
<div className={`px-5 py-4 rounded-2xl text-[11px] font-black uppercase tracking-[0.18em] ${accent ? "bg-primary text-white" : "bg-surface-container-low text-on-surface-variant"}`}>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{allImages.length > 0 && (
|
||||
<SectionCard title="Gambar Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allImages.map((imageId: string, i: number) => {
|
||||
const url = imgUrl(imageId);
|
||||
if (!url) return null;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={i} src={url} alt={`img-${i}`} className="w-20 h-20 object-cover rounded-lg border border-slate-100" />
|
||||
<img key={i} src={url} alt={`img-${i}`} className="h-32 w-full object-cover rounded-xl border border-surface-container" />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -408,33 +450,30 @@ function ProductColumn({
|
||||
<Row label="Deskripsi" value={product.description} />
|
||||
<Row label="Kategori" value={product.subCategory?.category?.name} />
|
||||
<Row label="Sub Kategori" value={product.subCategory?.name} />
|
||||
<Row label="Brand New" value={product.isNew} />
|
||||
<Row label="Eligible to Export" value={product.isEligibleToExport} />
|
||||
<Row label="Produk Baru" value={product.isNew} />
|
||||
<Row label="Bisa Diekspor" value={product.isEligibleToExport} />
|
||||
<Row label="Pre-order" value={product.isPreOrder} />
|
||||
{product.isPreOrder && <Row label="Pre-order Days" value={product.preOrderDay} />}
|
||||
<Row label="State" value={product.state} />
|
||||
{product.isPreOrder && <Row label="Durasi Pre-order" value={product.preOrderDay} />}
|
||||
<Row label="Status" value={product.state} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Features */}
|
||||
{features.length > 0 && (
|
||||
<SectionCard title="Fitur Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["productFeatures"])}>
|
||||
<ul className="space-y-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{features.map((f: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className={`material-symbols-outlined text-base mt-0.5 ${accent ? "text-primary" : "text-slate-400"}`} style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
<span key={i} className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed">{f}</span>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{keywords.length > 0 && (
|
||||
<SectionCard title="Keywords" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
|
||||
<SectionCard title="Kata Kunci" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((k: string) => (
|
||||
<span key={k} className={`px-3 py-1 rounded-full text-xs font-bold ${accent ? "bg-primary/10 text-primary" : "bg-slate-100 text-slate-600"}`}>{k}</span>
|
||||
<span key={k} className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
@ -443,26 +482,21 @@ function ProductColumn({
|
||||
{/* Models */}
|
||||
{models.length > 0 && (
|
||||
<SectionCard title={`Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}>
|
||||
<div className="space-y-3">
|
||||
{models.map((model: ReviewModel, index: number) => (
|
||||
<ModelCard
|
||||
key={`${model.sku || model.name || index}`}
|
||||
model={model}
|
||||
index={index}
|
||||
accent={accent}
|
||||
changed={hasChangesForPaths(compareRows, ["productModels"])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProductVariantShowcase
|
||||
product={product}
|
||||
warehouseLabelResolver={(warehouse) =>
|
||||
[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
|
||||
}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Compliance */}
|
||||
{product.complianceInformation && (
|
||||
<SectionCard title="Compliance" accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
|
||||
<SectionCard title="Kepatuhan" accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
|
||||
<Row label="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
|
||||
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} />
|
||||
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} />
|
||||
<Row label="Peringatan Keamanan" value={product.complianceInformation.safetyWarning} />
|
||||
<Row label="Barang Berbahaya" value={product.complianceInformation.isDangerousGoodRegulation} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
@ -614,6 +648,25 @@ function AdminReviewDetailPageInner() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const models = Array.isArray(product.productModels) ? product.productModels : [];
|
||||
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
|
||||
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords.filter(Boolean) : [];
|
||||
const productInfos = Array.isArray(product.productInformations)
|
||||
? product.productInformations.filter((item) => item.paramName && item.paramValue)
|
||||
: [];
|
||||
const categoryInfos = Array.isArray(product.categoryInformations)
|
||||
? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
|
||||
: [];
|
||||
const allImages = [
|
||||
...(product.imageId ? [product.imageId] : []),
|
||||
...(Array.isArray(product.productImages)
|
||||
? product.productImages
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map((item) => item.imageId)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
: []),
|
||||
];
|
||||
|
||||
// ── Reject modal ────────────────────────────────────────────────────────
|
||||
|
||||
const rejectModal = showRejectModal && (
|
||||
@ -660,59 +713,320 @@ function AdminReviewDetailPageInner() {
|
||||
<>
|
||||
{!isReadonly ? rejectModal : null}
|
||||
|
||||
<div className="m-6 space-y-6 pb-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-8 pb-16">
|
||||
<div className="mb-2">
|
||||
<nav className="mb-4 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
<button
|
||||
onClick={() => router.push(backHref)}
|
||||
className="transition-colors hover:text-primary"
|
||||
>
|
||||
{isReadonly ? "Produk" : "Review"}
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
<span className="text-primary">
|
||||
{isReadonly ? "Detail Produk" : isComparison ? "Review Update" : "Review Produk Baru"}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center">
|
||||
<button onClick={() => router.push(backHref)} className="hover:text-primary transition-colors">
|
||||
{isReadonly ? "Products" : "Reviews"}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-headline text-4xl font-black tracking-tighter text-on-surface">
|
||||
{product.name || "Review Detail"}
|
||||
</h1>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest border-l-2 ${
|
||||
isComparison
|
||||
? "border-secondary bg-secondary-fixed text-on-secondary-fixed-variant"
|
||||
: "border-amber-500 bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{isComparison ? "Review Update" : product.state || "Review Produk Baru"}
|
||||
</span>
|
||||
{product.seller?.name ? (
|
||||
<span className="text-sm text-slate-500">
|
||||
by{" "}
|
||||
<span className="font-semibold text-on-surface">
|
||||
{product.seller.name}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 rounded-xl border border-outline-variant/20 px-4 py-2 text-sm font-bold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
|
||||
<span className="text-primary">
|
||||
{isReadonly ? "Product Detail" : isComparison ? "Review Update" : "Review Produk Baru"}
|
||||
</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-black tracking-tight text-on-surface">{product.name}</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest border-l-2 ${isComparison ? "bg-secondary-fixed text-on-secondary-fixed-variant border-secondary" : "bg-amber-50 text-amber-700 border-amber-500"}`}>
|
||||
{isComparison ? "Update Review" : product.state}
|
||||
</span>
|
||||
{product.seller?.name && (
|
||||
<span className="text-sm text-slate-500">by <span className="font-semibold text-on-surface">{product.seller.name}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comparison notice */}
|
||||
{isComparison && (
|
||||
<div className="p-4 bg-secondary-fixed/50 rounded-xl flex items-center gap-3 text-sm font-semibold text-on-secondary-fixed-variant">
|
||||
<span className="material-symbols-outlined text-secondary">compare_arrows</span>
|
||||
Bandingkan perubahan yang diajukan seller (kiri) dengan data produk saat ini (kanan).
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="01" title="Ringkasan Review" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Kategori Utama</p>
|
||||
<p className="text-sm font-semibold text-on-surface">
|
||||
{product.subCategory?.category?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Sub Kategori</p>
|
||||
<p className="text-sm font-semibold text-on-surface">
|
||||
{product.subCategory?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Mode Review</p>
|
||||
<p className="text-sm font-semibold text-on-surface">
|
||||
{isComparison ? "Perbandingan update" : "Produk baru"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Penjual</p>
|
||||
<p className="text-sm font-semibold text-on-surface">
|
||||
{product.seller?.name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content — 1 column (isNew) or 2 columns (update) */}
|
||||
{isComparison ? (
|
||||
<div className="flex gap-6 items-start">
|
||||
<ProductColumn product={product} label="Versi Terbaru (Diajukan)" accent compareRows={compareRows} />
|
||||
<ProductColumn product={oldProduct} label="Versi Saat Ini (Live)" />
|
||||
<div className="rounded-2xl border border-secondary/15 bg-secondary-fixed/35 p-5 text-sm font-semibold text-on-secondary-fixed-variant shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-secondary">compare_arrows</span>
|
||||
Bandingkan perubahan yang diajukan seller dengan data produk yang saat ini live.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isComparison ? (
|
||||
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="02" title="Versi Diajukan" />
|
||||
<ProductColumn
|
||||
product={product}
|
||||
label="Versi Terbaru (Diajukan)"
|
||||
accent
|
||||
compareRows={compareRows}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="03" title="Versi Live Saat Ini" />
|
||||
<ProductColumn
|
||||
product={oldProduct}
|
||||
label="Versi Saat Ini (Live)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ProductColumn product={product} label="Produk Baru" accent />
|
||||
</div>
|
||||
<>
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="02" title="Detail Dasar" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Kategori Utama</p>
|
||||
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Sub Kategori</p>
|
||||
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.name || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 xl:grid-cols-12">
|
||||
<div className="space-y-6 rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm xl:col-span-7">
|
||||
<SectionHeader step="03" title="Deskripsi" />
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Nama Produk</p>
|
||||
<p className="text-base font-semibold text-on-surface">{product.name || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ToggleBadge label="Pre-order" value={!!product.isPreOrder} />
|
||||
<ToggleBadge label="Produk Baru" value={product.isNew !== false} />
|
||||
</div>
|
||||
|
||||
{product.isPreOrder ? (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Durasi Pre-order</p>
|
||||
<p className="text-sm font-semibold text-on-surface">{product.preOrderDay || "—"}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Deskripsi</p>
|
||||
<p className="whitespace-pre-line text-sm leading-7 text-on-surface-variant">{product.description || "—"}</p>
|
||||
</div>
|
||||
|
||||
{features.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Fitur Produk</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{keywords.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Kata Kunci</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((keyword) => (
|
||||
<span
|
||||
key={keyword}
|
||||
className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary"
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 xl:col-span-5">
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="04" title="Galeri" />
|
||||
{allImages.length ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allImages.map((imageId, index) => {
|
||||
const src = imgUrl(imageId);
|
||||
if (!src) return null;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={`${imageId}-${index}`}
|
||||
src={src}
|
||||
alt={`${product.name || "product"}-${index + 1}`}
|
||||
className="h-32 w-full rounded-xl border border-surface-container object-cover"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-xl bg-surface-container-low text-sm font-semibold text-outline">
|
||||
Tidak ada gambar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.seller ? (
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="05" title="Penjual" />
|
||||
<div className="flex items-center gap-4">
|
||||
{imgUrl(product.seller.imageId) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imgUrl(product.seller.imageId) || ""}
|
||||
alt={product.seller.name || "seller"}
|
||||
className="h-14 w-14 rounded-full border border-surface-container object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<span className="material-symbols-outlined">storefront</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-black text-on-surface">{product.seller.name || "-"}</p>
|
||||
<p className="mt-0.5 text-xs text-outline">ID: {product.seller.id || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="06" title="Model dan Harga" />
|
||||
<ProductVariantShowcase
|
||||
product={product}
|
||||
warehouseLabelResolver={(warehouse) =>
|
||||
[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="07" title="Informasi Tambahan" />
|
||||
{productInfos.length || categoryInfos.length ? (
|
||||
<div className="space-y-6">
|
||||
{productInfos.length ? (
|
||||
<div>
|
||||
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">Informasi Produk</p>
|
||||
<div className="space-y-1">
|
||||
{productInfos.map((item) => (
|
||||
<Row key={`${item.paramName}-${item.paramValue}`} label={item.paramName} value={item.paramValue} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{categoryInfos.length ? (
|
||||
<div>
|
||||
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">Informasi Kategori</p>
|
||||
<div className="space-y-1">
|
||||
{categoryInfos.map((item) => (
|
||||
<Row key={`${item.paramName}-${item.paramValue}`} label={item.paramName} value={item.paramValue} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-surface-container-low p-4 text-sm font-semibold text-outline">
|
||||
Tidak ada informasi tambahan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="08" title="Kepatuhan" />
|
||||
<div className="space-y-1">
|
||||
<Row label="Negara Asal" value={product.complianceInformation?.countryOfOrigin} />
|
||||
<Row label="Peringatan Keamanan" value={product.complianceInformation?.safetyWarning} />
|
||||
<Row label="Barang Berbahaya" value={product.complianceInformation?.isDangerousGoodRegulation} />
|
||||
<Row label="Bisa Diekspor" value={product.isEligibleToExport} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step="09" title="Garansi" />
|
||||
<div className="space-y-1">
|
||||
<Row label="Tipe" value={product.warrantyInformation?.type} />
|
||||
<Row
|
||||
label="Durasi"
|
||||
value={
|
||||
product.warrantyInformation?.duration
|
||||
? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType || ""}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Seller card (single, below both columns) */}
|
||||
{product.seller && (
|
||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-5">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-slate-400 mb-4 pb-3 border-b border-slate-100">Seller</h3>
|
||||
{product.seller && isComparison ? (
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step={isComparison ? "04" : "03"} title="Penjual" />
|
||||
<div className="flex items-center gap-4">
|
||||
{imgUrl(product.seller.imageId) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@ -728,11 +1042,11 @@ function AdminReviewDetailPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Action bar */}
|
||||
{!isReadonly ? (
|
||||
<div className="flex flex-col items-end gap-2 pt-2">
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
|
||||
<SectionHeader step={isComparison ? "05" : "10"} title="Tindakan Review" />
|
||||
{actionSuccess && (
|
||||
<div className="w-full p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
@ -745,32 +1059,36 @@ function AdminReviewDetailPageInner() {
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{acting && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
Memproses review...
|
||||
</p>
|
||||
)}
|
||||
{!actionSuccess && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => { setShowRejectModal(true); setActionError(""); }}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">block</span>
|
||||
{isComparison ? "Reject Update" : "Reject Product"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitReview("accept")}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
{acting ? (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
Memproses review...
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!actionSuccess ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => { setShowRejectModal(true); setActionError(""); }}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">block</span>
|
||||
{isComparison ? "Tolak Update" : "Tolak Produk"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitReview("accept")}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
{acting ? "Memproses..." : isComparison ? "Setujui Update" : "Setujui Produk"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -780,7 +1098,7 @@ function AdminReviewDetailPageInner() {
|
||||
|
||||
export default function AdminReviewDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="m-6 py-24 text-sm font-semibold text-slate-400">Loading review detail...</div>}>
|
||||
<Suspense fallback={<div className="m-6 py-24 text-sm font-semibold text-slate-400">Memuat detail review...</div>}>
|
||||
<AdminReviewDetailPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@ -9,7 +9,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const endpointMap: Record<string, string> = {
|
||||
draft: "/api/v1.0/seller/draft/product",
|
||||
"in-review": "/api/v1.0/product/review",
|
||||
"in-review": "/api/v1.0/seller/product/review",
|
||||
"international-market": "/api/v1.0/seller/international/product",
|
||||
"local-market": "/api/v1.0/seller/local/product",
|
||||
"out-of-stock": "/api/v1.0/seller/outofstock/product",
|
||||
|
||||
212
src/app/help/page.tsx
Normal file
212
src/app/help/page.tsx
Normal 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
109
src/app/privacy/page.tsx
Normal 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
109
src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
317
src/components/product-variant-showcase.tsx
Normal file
317
src/components/product-variant-showcase.tsx
Normal 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
206
src/lib/product-variants.ts
Normal 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) || "-";
|
||||
}
|
||||
@ -19,7 +19,7 @@ export const en = {
|
||||
emailOrPhone: "Email or Phone Number",
|
||||
password: "Password",
|
||||
forgotPassword: "Forgot password?",
|
||||
rememberDevice: "Remember this device for 30 days",
|
||||
rememberMe: "Remember me",
|
||||
submit: "Sign In",
|
||||
submitting: "Processing...",
|
||||
noAccount: "Don't have an account?",
|
||||
@ -80,7 +80,7 @@ export const en = {
|
||||
verifyFail: "Verification failed",
|
||||
registerFail: "Seller registration failed",
|
||||
successSeller:
|
||||
"OTP valid and seller account created. Redirecting to dashboard...",
|
||||
"OTP valid and seller account created. Redirecting to onboarding...",
|
||||
successBuyer:
|
||||
"OTP successfully verified. Redirecting to the next step...",
|
||||
securityTitle: "Institutional-Grade Security",
|
||||
|
||||
@ -19,7 +19,7 @@ export const id = {
|
||||
emailOrPhone: "Email atau Nomor HP",
|
||||
password: "Password",
|
||||
forgotPassword: "Lupa password?",
|
||||
rememberDevice: "Ingat perangkat ini selama 30 hari",
|
||||
rememberMe: "Ingat saya",
|
||||
submit: "Masuk",
|
||||
submitting: "Memproses...",
|
||||
noAccount: "Belum punya akun?",
|
||||
@ -81,7 +81,7 @@ export const id = {
|
||||
verifyFail: "Verifikasi gagal",
|
||||
registerFail: "Registrasi seller gagal",
|
||||
successSeller:
|
||||
"OTP valid dan akun seller berhasil dibuat. Mengalihkan ke dashboard...",
|
||||
"OTP valid dan akun seller berhasil dibuat. Mengalihkan ke onboarding...",
|
||||
successBuyer:
|
||||
"OTP berhasil diverifikasi. Mengalihkan ke langkah berikutnya...",
|
||||
securityTitle: "Keamanan Tingkat Institusional",
|
||||
|
||||
Reference in New Issue
Block a user