From 7e6446b4c29840e6d20cd0192c6369c756649bae Mon Sep 17 00:00:00 2001 From: Wira Irawan Date: Mon, 25 May 2026 10:34:57 +0700 Subject: [PATCH] Refine seller onboarding and product review flows --- HANDOFF.md | 198 ++++++ next.config.ts | 9 - src/app/(auth)/account-not-found/page.tsx | 10 +- src/app/(auth)/forgot-password/page.tsx | 58 +- src/app/(auth)/login/page.tsx | 62 +- src/app/(auth)/register/verify/page.tsx | 2 +- src/app/(dashboard)/dashboard/page.tsx | 243 +++++++- src/app/(dashboard)/layout.tsx | 47 +- .../products/[productId]/detail/page.tsx | 117 +--- src/app/(dashboard)/products/page.tsx | 97 ++- src/app/(onboarding)/layout.tsx | 15 +- src/app/(onboarding)/onboarding/plan/page.tsx | 363 +---------- .../onboarding/store-detail/page.tsx | 148 ++++- .../(onboarding)/onboarding/success/page.tsx | 5 +- src/app/admin/products/[productId]/page.tsx | 95 +-- src/app/admin/review/[productId]/page.tsx | 570 ++++++++++++++---- src/app/api/products/route.ts | 2 +- src/app/help/page.tsx | 212 +++++++ src/app/privacy/page.tsx | 109 ++++ src/app/terms/page.tsx | 109 ++++ src/components/product-variant-showcase.tsx | 317 ++++++++++ src/lib/product-variants.ts | 206 +++++++ src/lib/translations/en.ts | 4 +- src/lib/translations/id.ts | 4 +- 24 files changed, 2238 insertions(+), 764 deletions(-) create mode 100644 HANDOFF.md create mode 100644 src/app/help/page.tsx create mode 100644 src/app/privacy/page.tsx create mode 100644 src/app/terms/page.tsx create mode 100644 src/components/product-variant-showcase.tsx create mode 100644 src/lib/product-variants.ts diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..3085c97 --- /dev/null +++ b/HANDOFF.md @@ -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` + diff --git a/next.config.ts b/next.config.ts index efebc0b..2cdcdcd 100644 --- a/next.config.ts +++ b/next.config.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: [ diff --git a/src/app/(auth)/account-not-found/page.tsx b/src/app/(auth)/account-not-found/page.tsx index 28b1ccd..a7b0de5 100644 --- a/src/app/(auth)/account-not-found/page.tsx +++ b/src/app/(auth)/account-not-found/page.tsx @@ -41,7 +41,7 @@ function AccountNotFoundContent() {
help_outline @@ -104,12 +104,12 @@ function AccountNotFoundContent() {

{a.disclaimer}

diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index c78ce68..13eddf2 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -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} -
- - alternate_email - - 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" - /> +
+
+ + alternate_email + +
+
+

+ Recovery Contact +

+ 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" + /> +
-

- {/* privacy note kept short */} +

+ Gunakan email bisnis atau nomor HP yang terdaftar di akun Anda.

@@ -132,7 +143,12 @@ export default function ForgotPasswordPage() { contact_support

{f.havingTrouble}{" "} - {f.supportLink} + + {f.supportLink} +

@@ -143,9 +159,13 @@ export default function ForgotPasswordPage() {
- +
); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index b3d2489..29af4fc 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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" /> diff --git a/src/app/(auth)/register/verify/page.tsx b/src/app/(auth)/register/verify/page.tsx index cc72041..950fe53 100644 --- a/src/app/(auth)/register/verify/page.tsx +++ b/src/app/(auth)/register/verify/page.tsx @@ -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; } diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 416c802..900fa31 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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(null); + const [productResults, setProductResults] = useState([]); + const [warehouseResults, setWarehouseResults] = useState([]); + 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 (
@@ -96,6 +213,101 @@ export default function DashboardPage() {

{d.subtitle}

+ {dashboardQuery ? ( +
+
+
+

+ Dashboard Search +

+

+ Hasil pencarian untuk "{rawDashboardQuery}" +

+
+

+ {searchLoading + ? "Mencari data..." + : `${filteredOrders.length} order, ${productResults.length} produk, ${warehouseResults.length} warehouse`} +

+
+ +
+
+

+ Orders +

+
+ {filteredOrders.slice(0, 4).map((order) => ( +
+

{order.product}

+

{order.customer} · {order.location}

+
+ ))} + {!searchLoading && filteredOrders.length === 0 ? ( +

Tidak ada order yang cocok.

+ ) : null} +
+
+ +
+
+

+ Products +

+ + Lihat semua + +
+
+ {productResults.slice(0, 4).map((product) => ( + +

{product.name}

+

+ {product.market || product.state || product.status || "Produk"} +

+
+ ))} + {!searchLoading && productResults.length === 0 ? ( +

Tidak ada produk yang cocok.

+ ) : null} +
+
+ +
+
+

+ Warehouses +

+ + Lihat semua + +
+
+ {warehouseResults.slice(0, 4).map((warehouse) => ( + +

{warehouse.name || "Tanpa nama"}

+

+ {[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || warehouse.address || "Warehouse"} +

+
+ ))} + {!searchLoading && warehouseResults.length === 0 ? ( +

Tidak ada warehouse yang cocok.

+ ) : null} +
+
+
+
+ ) : null} +
@@ -220,7 +432,14 @@ export default function DashboardPage() {
-

{d.recentOrders}

+
+

{d.recentOrders}

+ {dashboardQuery ? ( +

+ Menampilkan hasil pencarian untuk "{searchParams.get("q")}" +

+ ) : null} +
-
diff --git a/src/app/(dashboard)/products/[productId]/detail/page.tsx b/src/app/(dashboard)/products/[productId]/detail/page.tsx index 9d95bfa..3cdbafb 100644 --- a/src/app/(dashboard)/products/[productId]/detail/page.tsx +++ b/src/app/(dashboard)/products/[productId]/detail/page.tsx @@ -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 && ( -
-
-
03
-

{d.section03} ({models.length})

-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {models.map((m: any, i: number) => { - const weightUnit = m.weightType || "G"; - const dimUnit = m.dimensionType || "CM"; - const pkgWeightUnit = m.packagingWeightType || "G"; - const pkgDimUnit = m.packagingDimensionType || "CM"; - const measurements = Array.isArray(m.productMeasurements) ? m.productMeasurements : []; - return ( -
-
-
{i + 1}
-

{m.name || `Model ${i + 1}`}

- {m.sku && SKU: {m.sku}} - {measurements.length > 0 && ( - {measurements.length} measurement(s) - )} -
-
- - - - {m.isConfigurePromotionPrice && } - {m.isConfigurePromotionPrice && m.promotionStartDate && ( - - )} - - -
- {/* Warehouses */} - {Array.isArray(m.warehouses) && m.warehouses.length > 0 && ( -
-

{d.warehouseStock}

-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => ( -
- {warehouseMap[w.id] || w.name || `${w.id?.slice(0, 8)}...`} - {w.stock ?? 0} unit -
- ))} -
-
- )} - {/* Measurements */} - {measurements.length > 0 && ( -
-

Measurements / Variants

-
- {measurements.map((ms: ProductMeasurement, mi: number) => { - const msWeightUnit = ms.weightType || "G"; - const msDimUnit = ms.dimensionType || "CM"; - return ( -
-
- - {String(mi + 1).padStart(2, "0")} - - {ms.measurementType && {ms.measurementType}} - {ms.measurementValue && — {ms.measurementValue}} -
-
- - - - {ms.isConfigurePromotionPrice && } -
- {Array.isArray(ms.warehouses) && - ms.warehouses.filter((w: ProductWarehouse) => w.id).length > 0 && ( -
-

Stock

- {ms.warehouses - .filter((w: ProductWarehouse) => w.id) - .map((w: ProductWarehouse, wi: number) => ( -
- {warehouseMap[w.id || ""] || `${w.id?.slice(0, 8)}...`} - {w.stock ?? 0} unit -
- ))} -
- )} -
- ); - })} -
-
- )} -
- ); - })} +
+ + + warehouse.id ? warehouseMap[String(warehouse.id)] || String(warehouse.id) : warehouse.name || "-" + } + />
)} diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx index a24e0b2..98bdb7b 100644 --- a/src/app/(dashboard)/products/page.tsx +++ b/src/app/(dashboard)/products/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Suspense, useEffect, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useLanguage } from "@/lib/i18n-context"; +import { getProductEffectivePoints } from "@/lib/product-variants"; type TabLabel = | "All Product" @@ -28,6 +29,7 @@ interface ProductRow { status?: string | null; reviewStatus?: string | null; totalStock: number; + productModels?: ProductModelRef[]; } type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string; @@ -103,17 +105,89 @@ function getToken() { } function formatPrice(product: ProductRow) { - if (!product.minPrice && !product.maxPrice) { - return "-"; + const minPrice = Number(product.minPrice); + const maxPrice = Number(product.maxPrice); + const hasApiRange = Number.isFinite(minPrice) && Number.isFinite(maxPrice) && (minPrice > 0 || maxPrice > 0); + + if (hasApiRange) { + const formatter = new Intl.NumberFormat("id-ID"); + if (minPrice === maxPrice) { + return `Rp ${formatter.format(minPrice)}`; + } + return `Rp ${formatter.format(minPrice)} - ${formatter.format(maxPrice)}`; } + const models = Array.isArray(product.productModels) ? product.productModels : []; + const prices = getProductEffectivePoints(models) + .map((point) => point.price) + .filter((value): value is number => value !== undefined && value > 0) + .sort((a, b) => a - b); + + if (prices.length === 0) return "-"; + const formatter = new Intl.NumberFormat("id-ID"); - - if (product.minPrice === product.maxPrice) { - return `Rp ${formatter.format(product.minPrice)}`; + if (prices[0] === prices[prices.length - 1]) { + return `Rp ${formatter.format(prices[0])}`; } - return `Rp ${formatter.format(product.minPrice)} - ${formatter.format(product.maxPrice)}`; + return `Rp ${formatter.format(prices[0])} - ${formatter.format(prices[prices.length - 1])}`; +} + +function hasMissingListPrice(product: ProductRow) { + const minPrice = Number(product.minPrice); + const maxPrice = Number(product.maxPrice); + return (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice) || (minPrice <= 0 && maxPrice <= 0)); +} + +async function hydrateRowsWithEffectivePrice( + rows: ProductRow[], + token: string, + query: string +) { + const targets = rows.filter(hasMissingListPrice); + if (targets.length === 0) return rows; + + const details = await Promise.all( + targets.map(async (row) => { + try { + const res = await fetch(`/api/products/${row.id}${query ? `?${query}` : ""}`, { + headers: { "x-auth-token": token }, + }); + const result = await res.json().catch(() => ({})); + if (!res.ok) return [row.id, null] as const; + const data = result?.data || result; + return [row.id, data] as const; + } catch { + return [row.id, null] as const; + } + }) + ); + + const detailMap = new Map(details); + + return rows.map((row) => { + const detail = detailMap.get(row.id); + if (!detail || !Array.isArray(detail.productModels)) return row; + + const prices = getProductEffectivePoints(detail.productModels) + .map((point) => point.price) + .filter((value): value is number => value !== undefined && value > 0) + .sort((a, b) => a - b); + + if (prices.length === 0) { + return { + ...row, + productModels: detail.productModels, + }; + } + + return { + ...row, + minPrice: prices[0], + maxPrice: prices[prices.length - 1], + productModels: detail.productModels, + }; + }); } function marketClasses(market: string) { @@ -745,9 +819,10 @@ function ProductsPageInner() { if (tab) params.set("tab", tab); params.set("page", String(page)); params.set("size", "20"); + const token = getToken(); const res = await fetch(`/api/products?${params.toString()}`, - { headers: { "x-auth-token": getToken() } } + { headers: { "x-auth-token": token } } ); const result = await res.json(); @@ -755,7 +830,13 @@ function ProductsPageInner() { throw new Error(result?.responseDesc || "Failed to load products"); } - setRows(result?.rows || result?.data?.rows || []); + const nextRows = result?.rows || result?.data?.rows || []; + const requestParams = new URLSearchParams(); + if (tab === "draft") requestParams.set("draft", "1"); + if (tab === "in-review") requestParams.set("review", "1"); + const hydratedRows = await hydrateRowsWithEffectivePrice(nextRows, token, requestParams.toString()); + + setRows(hydratedRows); setTotalItem(result?.totalItem || result?.data?.totalItem || 0); setTotalPage(result?.totalPage || result?.data?.totalPage || 0); } catch (err) { diff --git a/src/app/(onboarding)/layout.tsx b/src/app/(onboarding)/layout.tsx index 93869da..08163de 100644 --- a/src/app/(onboarding)/layout.tsx +++ b/src/app/(onboarding)/layout.tsx @@ -2,15 +2,13 @@ import Image from "next/image"; import Link from "next/link"; -import { useEffect } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; import { LanguageToggle } from "@/components/language-toggle"; import { useLanguage } from "@/lib/i18n-context"; const steps = [ { href: "/onboarding/business", icon: "storefront", labelKey: "business" as const, step: 1 }, { href: "/onboarding/store-detail", icon: "store", labelKey: "storeDetail" as const, step: 2 }, - { href: "/onboarding/plan", icon: "payments", labelKey: "plan" as const, step: 3 }, ]; export default function OnboardingLayout({ @@ -19,7 +17,6 @@ export default function OnboardingLayout({ children: React.ReactNode; }) { const pathname = usePathname(); - const router = useRouter(); const { t } = useLanguage(); const lo = t.onboarding.layout; @@ -27,16 +24,6 @@ export default function OnboardingLayout({ const currentStep = steps.find((s) => pathname.startsWith(s.href)); const stepNumber = currentStep?.step ?? (isSuccessPage ? steps.length : 1); - useEffect(() => { - if (pathname.startsWith("/onboarding")) { - router.replace("/dashboard"); - } - }, [pathname, router]); - - if (pathname.startsWith("/onboarding")) { - return null; - } - if (isSuccessPage) { return (
diff --git a/src/app/(onboarding)/onboarding/plan/page.tsx b/src/app/(onboarding)/onboarding/plan/page.tsx index e2e2a6a..2fb5f9b 100644 --- a/src/app/(onboarding)/onboarding/plan/page.tsx +++ b/src/app/(onboarding)/onboarding/plan/page.tsx @@ -1,371 +1,14 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useLanguage } from "@/lib/i18n-context"; - -type PlanId = "starter" | "professional" | "organization" | "enterprise"; - -interface PlanOption { - id: PlanId; - borderClass: string; - actionClass: string; - badge?: boolean; - featureKeys: string[]; - price: string; - unit: string; - seats: string; - ctaKey: "currentStep" | "selectPlan" | "contactUs"; -} - -const PLANS: PlanOption[] = [ - { - id: "starter", - price: "Free", - unit: "/mo", - seats: "1 Seat", - borderClass: "border-slate-200", - actionClass: "bg-surface-container-high text-on-surface hover:bg-surface-dim", - ctaKey: "currentStep", - featureKeys: ["basicAnalytics", "upTo10Trades", "apiAccess"], - }, - { - id: "professional", - price: "$29", - unit: "/mo", - seats: "5 Seats", - borderClass: "border-primary ring-2 ring-primary/20 shadow-2xl shadow-primary/10", - actionClass: "bg-gradient-to-br from-primary to-primary-container text-on-primary shadow-md shadow-primary/20 hover:opacity-95", - badge: true, - ctaKey: "selectPlan", - featureKeys: ["advancedReports", "unlimitedTrades", "exportToExcel"], - }, - { - id: "organization", - price: "$99", - unit: "/mo", - seats: "20 Seats", - borderClass: "border-tertiary/40", - actionClass: "bg-secondary text-on-secondary hover:opacity-90", - ctaKey: "selectPlan", - featureKeys: ["fullApiAccess", "customDashboards", "teamManagement"], - }, - { - id: "enterprise", - price: "Talk", - unit: "/sales", - seats: "Unlimited", - borderClass: "border-inverse-surface/30", - actionClass: "border-2 border-on-surface bg-transparent text-on-surface hover:bg-on-surface hover:text-white", - ctaKey: "contactUs", - featureKeys: ["slaGuarantee", "dedicatedManager", "customIntegrations"], - }, -]; - -const PLAN_NAMES: Record = { - starter: "Starter", - professional: "Professional", - organization: "Organization", - enterprise: "Enterprise", -}; - -const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft"; -const ONBOARDING_STORE_STORAGE_KEY = "onboardingStoreDetailDraft"; - -// Which features are included per plan (index 0,1,2 correspond to featureKeys) -const PLAN_INCLUDED: Record = { - starter: [true, true, false], - professional: [true, true, true], - organization: [true, true, true], - enterprise: [true, true, true], -}; - -function featureIconClass(planId: PlanId) { - if (planId === "organization") return "text-tertiary"; - if (planId === "enterprise") return "text-on-surface"; - return "text-primary"; -} export default function PlanPage() { const router = useRouter(); - const { t } = useLanguage(); - const p = t.onboarding.plan; - const common = t.common; - - const [selectedPlan, setSelectedPlan] = useState(() => { - if (typeof window === "undefined") return "professional"; - const storedPlan = sessionStorage.getItem("selectedPlan") as PlanId | null; - return storedPlan && PLANS.some((plan) => plan.id === storedPlan) - ? storedPlan - : "professional"; - }); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(""); - - function getToken() { - return ( - sessionStorage.getItem("token") || localStorage.getItem("token") || "" - ); - } useEffect(() => { - const token = getToken(); - const businessDraft = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY); - const storeDraft = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY); - - if (!token) { - router.replace("/login"); - return; - } - - if (!businessDraft) { - router.replace("/onboarding/business"); - return; - } - - if (!storeDraft) { - router.replace("/onboarding/store-detail"); - } + router.replace("/onboarding/store-detail"); }, [router]); - function persistPlan(planId: PlanId) { - setSelectedPlan(planId); - sessionStorage.setItem("selectedPlan", planId); - } - - async function handleContinue() { - setError(""); - setSubmitting(true); - - try { - sessionStorage.setItem("selectedPlan", selectedPlan); - - const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY); - if (!businessDraftRaw) { - throw new Error("Business onboarding data not found"); - } - - const storeDraftRaw = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY); - if (!storeDraftRaw) { - throw new Error("Store detail onboarding data not found"); - } - - const businessDraft = JSON.parse(businessDraftRaw) as { - payload?: Record; - }; - - const storeDraft = JSON.parse(storeDraftRaw) as { - payload?: { - store: Record; - warehouse: Record; - }; - }; - - if (!businessDraft.payload) { - throw new Error("Business onboarding payload not found"); - } - - if (!storeDraft.payload?.store || !storeDraft.payload?.warehouse) { - throw new Error("Store detail onboarding payload not found"); - } - - const sellerRes = await fetch("/api/seller", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-auth-token": getToken(), - }, - body: JSON.stringify(businessDraft.payload), - }); - - const sellerData = await sellerRes.json().catch(() => ({})); - if (!sellerRes.ok) { - throw new Error( - sellerData?.error || sellerData?.responseDesc || common.connectionError - ); - } - - const storeRes = await fetch("/api/seller/store", { - method: "PUT", - headers: { - "Content-Type": "application/json", - "x-auth-token": getToken(), - }, - body: JSON.stringify(storeDraft.payload.store), - }); - - const storeData = await storeRes.json().catch(() => ({})); - if (!storeRes.ok) { - throw new Error( - storeData?.error || storeData?.responseDesc || common.connectionError - ); - } - - const warehouseRes = await fetch("/api/warehouses", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-auth-token": getToken(), - }, - body: JSON.stringify(storeDraft.payload.warehouse), - }); - - const warehouseData = await warehouseRes.json().catch(() => ({})); - if (!warehouseRes.ok) { - throw new Error( - warehouseData?.error || - warehouseData?.responseDesc || - common.connectionError - ); - } - - sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY); - sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY); - router.push("/onboarding/success"); - } catch (err) { - setError( - err instanceof Error ? err.message : common.connectionError - ); - } finally { - setSubmitting(false); - } - } - - return ( - <> -
-
-
-
- {error ? ( -
- {error} -
- ) : null} -
- - {p.step} - -
-

- {p.title} -

-

- {p.subtitle} -

-
- -
- {PLANS.map((plan) => { - const isSelected = selectedPlan === plan.id; - const included = PLAN_INCLUDED[plan.id]; - - return ( -
- {plan.badge ? ( -
- {p.mostPopular} -
- ) : null} - -

- {PLAN_NAMES[plan.id]} -

- -
- {plan.price} - {plan.unit} -
- -
-

- group - {plan.seats} -

- -
- {plan.featureKeys.map((key, idx) => { - const featureLabel = p.features[key as keyof typeof p.features]; - const isIncluded = included[idx]; - return ( -
- - {isIncluded ? "check" : "close"} - - {featureLabel} -
- ); - })} -
-
- - -
- ); - })} -
-
-
-
- -
-
- -
- - -
-
-
- - ); + return null; } diff --git a/src/app/(onboarding)/onboarding/store-detail/page.tsx b/src/app/(onboarding)/onboarding/store-detail/page.tsx index 39ec4a8..a389935 100644 --- a/src/app/(onboarding)/onboarding/store-detail/page.tsx +++ b/src/app/(onboarding)/onboarding/store-detail/page.tsx @@ -17,8 +17,6 @@ const headlineFieldClass = const textareaClass = "w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-3.5 text-sm leading-relaxed text-on-surface shadow-sm transition-all focus:border-primary focus:bg-surface-container-lowest focus:outline-none"; -type WarehouseType = "INA" | "OTHER"; - function toNumber(value: string) { const normalized = value.trim(); if (!normalized) return 0; @@ -26,10 +24,21 @@ function toNumber(value: string) { return Number.isFinite(parsed) ? parsed : 0; } +type WarehouseListRow = { + id?: string; + name?: string | null; + address?: string | null; + city?: string | null; + province?: string | null; + postalCode?: string | null; + warehouseType?: string | null; +}; + export default function StoreDetailPage() { const router = useRouter(); const { t } = useLanguage(); const sd = t.onboarding.storeDetail; + const common = t.common; const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -42,7 +51,7 @@ export default function StoreDetailPage() { const [warehouse, setWarehouse] = useState({ name: "", address: "", - warehouseType: "INA" as WarehouseType, + warehouseType: "INA", country: "Indonesia", province: "", city: "", @@ -115,7 +124,7 @@ export default function StoreDetailPage() { } }, [router]); - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); @@ -158,6 +167,22 @@ export default function StoreDetailPage() { }, }; + const businessDraftRaw = sessionStorage.getItem( + ONBOARDING_BUSINESS_STORAGE_KEY + ); + + if (!businessDraftRaw) { + throw new Error(sd.genericError); + } + + const businessDraft = JSON.parse(businessDraftRaw) as { + payload?: Record; + }; + + if (!businessDraft.payload) { + throw new Error(sd.genericError); + } + sessionStorage.setItem( ONBOARDING_STORE_STORAGE_KEY, JSON.stringify({ @@ -167,9 +192,101 @@ export default function StoreDetailPage() { }) ); - router.push("/onboarding/plan"); + const sellerRes = await fetch("/api/seller", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-auth-token": getToken(), + }, + body: JSON.stringify(businessDraft.payload), + }); + + const sellerData = await sellerRes.json().catch(() => ({})); + if (!sellerRes.ok) { + throw new Error( + sellerData?.error || sellerData?.responseDesc || common.connectionError + ); + } + + const storeRes = await fetch("/api/seller/store", { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-auth-token": getToken(), + }, + body: JSON.stringify(payload.store), + }); + + const storeData = await storeRes.json().catch(() => ({})); + if (!storeRes.ok) { + throw new Error( + storeData?.error || storeData?.responseDesc || common.connectionError + ); + } + + const warehouseListRes = await fetch("/api/products/warehouses?page=1&size=100", { + headers: { + "x-auth-token": getToken(), + }, + }); + + const warehouseListData = await warehouseListRes.json().catch(() => ({})); + const warehouseRows: WarehouseListRow[] = Array.isArray(warehouseListData?.rows) + ? warehouseListData.rows + : []; + + const normalizedWarehouseAddress = payload.warehouse.address.trim().toLowerCase(); + const normalizedWarehouseCity = payload.warehouse.city.trim().toLowerCase(); + const normalizedWarehouseProvince = payload.warehouse.province.trim().toLowerCase(); + const normalizedWarehousePostalCode = payload.warehouse.postalCode.trim().toLowerCase(); + + const autogeneratedWarehouse = warehouseRows.find((item) => { + const normalizedItemAddress = (item.address || "").trim().toLowerCase(); + const normalizedItemCity = (item.city || "").trim().toLowerCase(); + const normalizedItemProvince = (item.province || "").trim().toLowerCase(); + const normalizedItemPostalCode = (item.postalCode || "").trim().toLowerCase(); + const hasNoConfiguredName = !item.name || !item.name.trim(); + const hasNoConfiguredType = !item.warehouseType || !item.warehouseType.trim(); + const sameAddress = + normalizedItemAddress === normalizedWarehouseAddress && + normalizedItemCity === normalizedWarehouseCity && + normalizedItemProvince === normalizedWarehouseProvince && + normalizedItemPostalCode === normalizedWarehousePostalCode; + + return Boolean(item.id) && (sameAddress || hasNoConfiguredName || hasNoConfiguredType); + }); + + const warehouseEndpoint = autogeneratedWarehouse?.id + ? `/api/warehouses/${autogeneratedWarehouse.id}` + : "/api/warehouses"; + const warehouseMethod = autogeneratedWarehouse?.id ? "PUT" : "POST"; + + const warehouseRes = await fetch(warehouseEndpoint, { + method: warehouseMethod, + headers: { + "Content-Type": "application/json", + "x-auth-token": getToken(), + }, + body: JSON.stringify(payload.warehouse), + }); + + const warehouseData = await warehouseRes.json().catch(() => ({})); + if (!warehouseRes.ok) { + throw new Error( + warehouseData?.error || + warehouseData?.responseDesc || + common.connectionError + ); + } + + sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY); + sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY); + sessionStorage.removeItem("selectedPlan"); + router.push("/onboarding/success"); } catch (err) { - setError(err instanceof Error ? err.message : sd.genericError); + setError( + err instanceof Error ? err.message : common.connectionError + ); } finally { setSubmitting(false); } @@ -299,25 +416,6 @@ export default function StoreDetailPage() { />
-
- - -
-
diff --git a/src/app/admin/review/[productId]/page.tsx b/src/app/admin/review/[productId]/page.tsx index da90e6d..2236bf2 100644 --- a/src/app/admin/review/[productId]/page.tsx +++ b/src/app/admin/review/[productId]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { ProductVariantShowcase } from "@/components/product-variant-showcase"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; @@ -50,7 +51,7 @@ function formatMeasurementLabel( index: number ) { const parts = [measurement.measurementType, measurement.measurementValue].filter(Boolean); - return parts.length > 0 ? parts.join(" - ") : `Measurement ${index + 1}`; + return parts.length > 0 ? parts.join(" - ") : `Ukuran ${index + 1}`; } interface ReviewWarehouse { @@ -86,6 +87,11 @@ interface ReviewModel extends ReviewMeasurement { productMeasurements?: ReviewMeasurement[]; } +interface ReviewInfoItem { + paramName: string; + paramValue: string; +} + interface ReviewProductData { name?: string; description?: string; @@ -100,6 +106,8 @@ interface ReviewProductData { productModels?: ReviewModel[]; productKeyWords?: string[]; productFeatures?: string[]; + productInformations?: ReviewInfoItem[]; + categoryInformations?: ReviewInfoItem[]; subCategory?: { name?: string; category?: { @@ -162,39 +170,43 @@ function ModelCard({ const modelDimension = formatDimension(model.length, model.width, model.height, model.dimensionType); return ( -
+
{imgUrl(model.imageId) && ( // eslint-disable-next-line @next/next/no-img-element - {model.name} + {model.name} )}

{model.name || `Model ${index + 1}`}

{changed && (

- Updated + Diperbarui

)} {hasMeasurements && (

- {measurements.length} measurement variation(s) + {measurements.length} variasi ukuran

)}
- - - - {model.isConfigurePromotionPrice && !hasMeasurements && ( - - )} + {!hasMeasurements ? ( + <> + + + + {model.isConfigurePromotionPrice && ( + + )} + + ) : null} {!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && ( -
-

Warehouse & Stok

+
+

Warehouse & Stok

{model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
- {[warehouse.city, warehouse.province].filter(Boolean).join(", ")} + {[warehouse.city, warehouse.province].filter(Boolean).join(", ")} {warehouse.stock ?? 0} unit
))} @@ -202,7 +214,7 @@ function ModelCard({ )} {hasMeasurements && ( -
+
{measurements.map((measurement: ReviewMeasurement, measurementIndex: number) => { const measurementPrice = formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-"; @@ -221,7 +233,7 @@ function ModelCard({ return (

{formatMeasurementLabel(measurement, measurementIndex)} @@ -233,11 +245,11 @@ function ModelCard({ )} {Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && ( -

-

Warehouse & Stok

+
+

Warehouse & Stok

{measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
- {[warehouse.city, warehouse.province].filter(Boolean).join(", ")} + {[warehouse.city, warehouse.province].filter(Boolean).join(", ")} {warehouse.stock ?? 0} unit
))} @@ -258,13 +270,39 @@ function Row({ label, value }: { label: string; value?: string | number | boolea if (value === "" || value === undefined || value === null) return null; const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value); return ( -
- {label} +
+ {label} {display}
); } +function ToggleBadge({ label, value }: { label: string; value: boolean }) { + return ( +
+ {label} + + {value ? "Ya" : "Tidak"} + +
+ ); +} + +function SectionHeader({ step, title }: { step: string; title: string }) { + return ( +
+
+ {step} +
+

{title}

+
+ ); +} + function SectionCard({ title, accent, @@ -277,14 +315,14 @@ function SectionCard({ children: React.ReactNode; }) { return ( -
-
-

+
+
+

{title}

{changed && ( - Updated + Diperbarui )}
@@ -355,6 +393,12 @@ function ProductColumn({ const images = Array.isArray(product.productImages) ? product.productImages : []; const features = Array.isArray(product.productFeatures) ? product.productFeatures : []; const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : []; + const productInfos = Array.isArray(product.productInformations) + ? product.productInformations.filter((item) => item.paramName && item.paramValue) + : []; + const categoryInfos = Array.isArray(product.categoryInformations) + ? product.categoryInformations.filter((item) => item.paramName && item.paramValue) + : []; const allImages: string[] = [ ...(product.imageId ? [product.imageId] : []), ...images @@ -368,21 +412,19 @@ function ProductColumn({ return (
- {/* Column label */} -
+
{label}
- {/* Images */} {allImages.length > 0 && ( -
+
{allImages.map((imageId: string, i: number) => { const url = imgUrl(imageId); if (!url) return null; return ( // eslint-disable-next-line @next/next/no-img-element - {`img-${i}`} + {`img-${i}`} ); })}
@@ -408,33 +450,30 @@ function ProductColumn({ - - + + - {product.isPreOrder && } - + {product.isPreOrder && } + {/* Features */} {features.length > 0 && ( -
    +
    {features.map((f: string, i: number) => ( -
  • - check_circle - {f} -
  • + {f} ))} -
+
)} {/* Keywords */} {keywords.length > 0 && ( - +
{keywords.map((k: string) => ( - {k} + {k} ))}
@@ -443,26 +482,21 @@ function ProductColumn({ {/* Models */} {models.length > 0 && ( -
- {models.map((model: ReviewModel, index: number) => ( - - ))} -
+ + [warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-") + } + />
)} {/* Compliance */} {product.complianceInformation && ( - + - - + + )} @@ -614,6 +648,25 @@ function AdminReviewDetailPageInner() {
); + const models = Array.isArray(product.productModels) ? product.productModels : []; + const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : []; + const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords.filter(Boolean) : []; + const productInfos = Array.isArray(product.productInformations) + ? product.productInformations.filter((item) => item.paramName && item.paramValue) + : []; + const categoryInfos = Array.isArray(product.categoryInformations) + ? product.categoryInformations.filter((item) => item.paramName && item.paramValue) + : []; + const allImages = [ + ...(product.imageId ? [product.imageId] : []), + ...(Array.isArray(product.productImages) + ? product.productImages + .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) + .map((item) => item.imageId) + .filter((value): value is string => Boolean(value)) + : []), + ]; + // ── Reject modal ──────────────────────────────────────────────────────── const rejectModal = showRejectModal && ( @@ -660,59 +713,320 @@ function AdminReviewDetailPageInner() { <> {!isReadonly ? rejectModal : null} -
- {/* Header */} -
+
+
+ +
- -

{product.name}

-
- - {isComparison ? "Update Review" : product.state} - - {product.seller?.name && ( - by {product.seller.name} - )}
-
- {/* Comparison notice */} - {isComparison && ( -
- compare_arrows - Bandingkan perubahan yang diajukan seller (kiri) dengan data produk saat ini (kanan). +
+ +
+
+

Kategori Utama

+

+ {product.subCategory?.category?.name || "—"} +

+
+
+

Sub Kategori

+

+ {product.subCategory?.name || "—"} +

+
+
+

Mode Review

+

+ {isComparison ? "Perbandingan update" : "Produk baru"} +

+
+
+

Penjual

+

+ {product.seller?.name || "-"} +

+
- )} +
- {/* Content — 1 column (isNew) or 2 columns (update) */} {isComparison ? ( -
- - +
+
+ compare_arrows + Bandingkan perubahan yang diajukan seller dengan data produk yang saat ini live. +
+
+ ) : null} + + {isComparison ? ( +
+
+ + +
+
+ + +
) : ( -
- -
+ <> +
+ +
+
+

Kategori Utama

+

{product.subCategory?.category?.name || "—"}

+
+
+

Sub Kategori

+

{product.subCategory?.name || "—"}

+
+
+
+ +
+
+ + +
+

Nama Produk

+

{product.name || "—"}

+
+ +
+ + +
+ + {product.isPreOrder ? ( +
+

Durasi Pre-order

+

{product.preOrderDay || "—"}

+
+ ) : null} + +
+

Deskripsi

+

{product.description || "—"}

+
+ + {features.length ? ( +
+

Fitur Produk

+
+ {features.map((feature) => ( + + {feature} + + ))} +
+
+ ) : null} + + {keywords.length ? ( +
+

Kata Kunci

+
+ {keywords.map((keyword) => ( + + {keyword} + + ))} +
+
+ ) : null} +
+ +
+
+ + {allImages.length ? ( +
+ {allImages.map((imageId, index) => { + const src = imgUrl(imageId); + if (!src) return null; + return ( + // eslint-disable-next-line @next/next/no-img-element + {`${product.name + ); + })} +
+ ) : ( +
+ Tidak ada gambar +
+ )} +
+ + {product.seller ? ( +
+ +
+ {imgUrl(product.seller.imageId) ? ( + // eslint-disable-next-line @next/next/no-img-element + {product.seller.name + ) : ( +
+ storefront +
+ )} +
+

{product.seller.name || "-"}

+

ID: {product.seller.id || "-"}

+
+
+
+ ) : null} +
+
+ +
+ + + [warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || (warehouse.id != null ? String(warehouse.id) : "-") + } + /> +
+ +
+
+ + {productInfos.length || categoryInfos.length ? ( +
+ {productInfos.length ? ( +
+

Informasi Produk

+
+ {productInfos.map((item) => ( + + ))} +
+
+ ) : null} + + {categoryInfos.length ? ( +
+

Informasi Kategori

+
+ {categoryInfos.map((item) => ( + + ))} +
+
+ ) : null} +
+ ) : ( +
+ Tidak ada informasi tambahan +
+ )} +
+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + +
+
+
+
+ )} - {/* Seller card (single, below both columns) */} - {product.seller && ( -
-

Seller

+ {product.seller && isComparison ? ( +
+
{imgUrl(product.seller.imageId) ? ( // eslint-disable-next-line @next/next/no-img-element @@ -728,11 +1042,11 @@ function AdminReviewDetailPageInner() {
- )} + ) : null} - {/* Action bar */} {!isReadonly ? ( -
+
+ {actionSuccess && (
check_circle @@ -745,32 +1059,36 @@ function AdminReviewDetailPageInner() { {actionError}
)} - {acting && ( -

- progress_activity - Memproses review... -

- )} - {!actionSuccess && ( -
- - -
- )} + +
+ {acting ? ( +

+ progress_activity + Memproses review... +

+ ) : null} + + {!actionSuccess ? ( +
+ + +
+ ) : null} +
) : null}
@@ -780,7 +1098,7 @@ function AdminReviewDetailPageInner() { export default function AdminReviewDetailPage() { return ( - Loading review detail...
}> + Memuat detail review...
}> ); diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts index 5f9a352..dd18033 100644 --- a/src/app/api/products/route.ts +++ b/src/app/api/products/route.ts @@ -9,7 +9,7 @@ export async function GET(req: NextRequest) { const endpointMap: Record = { draft: "/api/v1.0/seller/draft/product", - "in-review": "/api/v1.0/product/review", + "in-review": "/api/v1.0/seller/product/review", "international-market": "/api/v1.0/seller/international/product", "local-market": "/api/v1.0/seller/local/product", "out-of-stock": "/api/v1.0/seller/outofstock/product", diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx new file mode 100644 index 0000000..15abde8 --- /dev/null +++ b/src/app/help/page.tsx @@ -0,0 +1,212 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { LanguageToggle } from "@/components/language-toggle"; + +const supportEmail = "admin@inatrading.co.id"; + +const categories = [ + { + icon: "lock_reset", + title: "Akses Akun", + description: + "Panduan login, reset password, dan pemulihan akses akun seller atau buyer.", + }, + { + icon: "storefront", + title: "Onboarding Seller", + description: + "Bantuan pengisian data bisnis, detail toko, dan tahap aktivasi akun seller.", + }, + { + icon: "inventory_2", + title: "Produk & Stok", + description: + "Informasi pengelolaan produk, harga, warehouse, dan alur review produk.", + }, + { + icon: "verified_user", + title: "Verifikasi & Kepatuhan", + description: + "Dokumen legal, verifikasi identitas, dan kebutuhan kepatuhan perdagangan.", + }, +]; + +const quickAnswers = [ + { + question: "Saya tidak menerima email reset password.", + answer: + "Periksa folder spam/junk terlebih dahulu. Jika masih tidak ada, hubungi admin agar kami bisa bantu verifikasi akun Anda secara manual.", + }, + { + question: "Saya tidak bisa menyelesaikan onboarding seller.", + answer: + "Pastikan data bisnis, dokumen, dan detail toko sudah terisi lengkap. Jika ada field yang membingungkan, kirim email beserta screenshot error yang muncul.", + }, + { + question: "Saya sudah login tetapi diarahkan ke onboarding lagi.", + answer: + "Itu biasanya terjadi saat profil seller belum lengkap. Lengkapi data bisnis dan profil toko sampai semua tahap onboarding selesai.", + }, +]; + +export default function PublicHelpPage() { + return ( +
+
+
+
+
+
+ +
+
+ + Ina Trading + + +
+ +
+
+

+ Public Help Center +

+

+ Bantuan cepat tanpa perlu login. +

+

+ Gunakan halaman ini untuk bantuan akses akun, onboarding seller, + dan pertanyaan umum seputar penggunaan Ina Trading. +

+
+ +
+
+ support_agent +
+

+ Butuh bantuan langsung? +

+

+ Untuk kendala akun atau onboarding, kirim email ke admin kami + dan sertakan email akun, nomor HP, serta screenshot jika ada. +

+ + mail + Email Admin + +

+ {supportEmail} +

+
+
+
+
+ +
+
+
+

+ Fokus Bantuan +

+

+ Area yang paling sering ditanyakan +

+
+
+
+ +
+ {categories.map((item) => ( +
+ + {item.icon} + +

+ {item.title} +

+

+ {item.description} +

+
+ ))} +
+
+ +
+
+
+

+ Quick Answers +

+

+ Pertanyaan umum +

+
+ {quickAnswers.map((item) => ( +
+

+ {item.question} +

+

+ {item.answer} +

+
+ ))} +
+
+ +
+

+ Contact +

+

+ Hubungi tim admin +

+

+ Jika bantuan mandiri belum cukup, kirim email dengan detail + kendala Anda. Default email app di perangkat Anda akan terbuka. +

+ +
+

+ Email tujuan +

+

+ {supportEmail} +

+
+ +
+ + outgoing_mail + Buka Email App + + + arrow_back + Kembali ke Login + +
+
+
+
+
+ ); +} diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 0000000..f394496 --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { LanguageToggle } from "@/components/language-toggle"; + +const sections = [ + { + title: "Data yang Kami Kumpulkan", + body: + "Kami dapat mengumpulkan data identitas, data kontak, informasi bisnis, dokumen pendukung, serta informasi teknis yang diperlukan untuk pengoperasian platform Ina Trading.", + }, + { + title: "Cara Kami Menggunakan Data", + body: + "Data digunakan untuk verifikasi akun, pemrosesan onboarding, pengelolaan transaksi, dukungan pelanggan, peningkatan layanan, dan pemenuhan kewajiban regulasi yang berlaku.", + }, + { + title: "Penyimpanan dan Perlindungan", + body: + "Kami menerapkan kontrol teknis dan administratif yang wajar untuk menjaga kerahasiaan, integritas, dan ketersediaan data yang diproses melalui platform kami.", + }, + { + title: "Berbagi dengan Pihak Ketiga", + body: + "Data hanya dibagikan sejauh diperlukan untuk operasional platform, verifikasi, layanan logistik, kewajiban hukum, atau saat diwajibkan oleh regulator yang berwenang.", + }, +]; + +export default function PrivacyPage() { + return ( +
+
+
+
+ + Ina Trading + +
+ + + help_outline + Bantuan + +
+
+ +
+

+ Public Policy +

+

+ Kebijakan Privasi +

+

+ Halaman ini menjelaskan secara ringkas bagaimana Ina Trading mengelola + dan melindungi data pengguna tanpa memerlukan login terlebih dahulu. +

+
+
+
+ +
+
+ {sections.map((section) => ( +
+

+ {section.title} +

+

+ {section.body} +

+
+ ))} +
+ +
+

+ Jika Anda membutuhkan klarifikasi lebih lanjut terkait privasi data, + silakan kunjungi halaman bantuan publik atau hubungi tim admin melalui + email resmi Ina Trading. +

+
+ + support_agent + Buka Bantuan + + + gavel + Lihat Syarat + +
+
+
+
+ ); +} diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx new file mode 100644 index 0000000..9f60adc --- /dev/null +++ b/src/app/terms/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { LanguageToggle } from "@/components/language-toggle"; + +const sections = [ + { + title: "Penggunaan Layanan", + body: + "Pengguna bertanggung jawab atas keakuratan data akun, kepatuhan terhadap peraturan perdagangan yang berlaku, dan penggunaan platform secara sah serta tidak menyesatkan.", + }, + { + title: "Akun dan Keamanan", + body: + "Pengguna wajib menjaga kerahasiaan kredensial akun. Setiap aktivitas yang terjadi melalui akun pengguna menjadi tanggung jawab pemilik akun kecuali ditentukan lain oleh hukum yang berlaku.", + }, + { + title: "Konten dan Dokumen", + body: + "Semua dokumen, gambar, dan data yang diunggah harus merupakan milik pengguna atau telah diotorisasi secara sah untuk digunakan dalam proses operasional Ina Trading.", + }, + { + title: "Pembatasan Tanggung Jawab", + body: + "Ina Trading berupaya menyediakan layanan sebaik mungkin, namun tidak menjamin layanan bebas gangguan setiap saat dan berhak melakukan perubahan, peninjauan, atau pembatasan akses sesuai kebutuhan operasional dan kepatuhan.", + }, +]; + +export default function TermsPage() { + return ( +
+
+
+
+ + Ina Trading + +
+ + + help_outline + Bantuan + +
+
+ +
+

+ Public Policy +

+

+ Syarat & Ketentuan +

+

+ Ringkasan ini disediakan untuk akses publik tanpa login agar + pengguna dapat memahami kerangka penggunaan platform Ina Trading. +

+
+
+
+ +
+
+ {sections.map((section) => ( +
+

+ {section.title} +

+

+ {section.body} +

+
+ ))} +
+ +
+

+ Dengan melanjutkan penggunaan platform, Anda dianggap memahami dan + menyetujui kerangka syarat dasar ini. Untuk bantuan lebih lanjut, + gunakan halaman bantuan publik. +

+
+ + support_agent + Buka Bantuan + + + shield_lock + Lihat Privasi + +
+
+
+
+ ); +} diff --git a/src/components/product-variant-showcase.tsx b/src/components/product-variant-showcase.tsx new file mode 100644 index 0000000..c076394 --- /dev/null +++ b/src/components/product-variant-showcase.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + buildMeasurementLabel, + formatDimension, + formatMoney, + formatWeight, + getAllProductImageIds, + getEffectiveDimensionLabel, + getEffectivePriceLabel, + getEffectiveWeightLabel, + getModelMeasurements, + getModelPriceLabel, + modelHasMeasurements, + type VariantMeasurementLike, + type VariantModelLike, + type VariantProductLike, + type VariantWarehouseLike, +} from "@/lib/product-variants"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; + +function imgUrl(id?: string | null) { + if (!id) return null; + if (id.startsWith("http")) return id; + return `${API_BASE}/api/v1.0/file/image/${id}`; +} + +function DetailRow({ label, value }: { label: string; value?: string | number | null }) { + if (value === "" || value === undefined || value === null) return null; + return ( +
+ {label} + {String(value)} +
+ ); +} + +type Labels = { + summaryTitle: string; + structureTitle: string; + productPrice: string; + productWeight: string; + productDimension: string; + modelLabel: string; + measurementLabel: string; + selectedDetailTitle: string; + noMeasurement: string; + noModels: string; + price: string; + weight: string; + dimension: string; + promoPrice: string; + sku: string; + stock: string; + variants: string; + varied: string; +}; + +const defaultLabels: Labels = { + summaryTitle: "Ringkasan Varian", + structureTitle: "Struktur Varian", + productPrice: "Harga Produk", + productWeight: "Berat Produk", + productDimension: "Dimensi Produk", + modelLabel: "Model", + measurementLabel: "Ukuran", + selectedDetailTitle: "Detail Varian Terpilih", + noMeasurement: "Model ini tidak memiliki ukuran tambahan", + noModels: "Tidak ada model produk", + price: "Harga", + weight: "Berat", + dimension: "Dimensi", + promoPrice: "Harga Promo", + sku: "SKU", + stock: "Warehouse & Stok", + variants: "variasi ukuran", + varied: "Bervariasi", +}; + +function getWarehouseLabel( + warehouse: VariantWarehouseLike, + resolver?: (warehouse: VariantWarehouseLike) => string +) { + if (resolver) return resolver(warehouse); + return [warehouse.name, warehouse.city, warehouse.province].filter(Boolean).join(", ") || warehouse.id || "-"; +} + +export function ProductVariantShowcase({ + product, + warehouseLabelResolver, + labels, +}: { + product: VariantProductLike; + warehouseLabelResolver?: (warehouse: VariantWarehouseLike) => string; + labels?: Partial; +}) { + const l = { ...defaultLabels, ...labels }; + const models = Array.isArray(product.productModels) ? product.productModels : []; + const productImages = getAllProductImageIds(product); + const [selectedModelIndex, setSelectedModelIndex] = useState(0); + const [selectedMeasurementIndex, setSelectedMeasurementIndex] = useState(0); + + useEffect(() => { + setSelectedModelIndex(0); + setSelectedMeasurementIndex(0); + }, [product]); + + const selectedModel = models[selectedModelIndex] || null; + const measurements = selectedModel ? getModelMeasurements(selectedModel) : []; + const hasMeasurements = selectedModel ? modelHasMeasurements(selectedModel) : false; + const selectedMeasurement = hasMeasurements ? measurements[selectedMeasurementIndex] || measurements[0] || null : null; + const selectedImageId = + (selectedModel?.imageId as string | undefined) || + productImages[0] || + null; + const selectedImageUrl = imgUrl(selectedImageId); + + const selectedPrice = selectedMeasurement + ? formatMoney(selectedMeasurement.price, selectedMeasurement.currency || selectedModel?.currency) + : formatMoney(selectedModel?.price, selectedModel?.currency); + const selectedWeight = selectedMeasurement + ? formatWeight(selectedMeasurement.weight, selectedMeasurement.weightType || selectedModel?.weightType) + : formatWeight(selectedModel?.weight, selectedModel?.weightType); + const selectedDimension = selectedMeasurement + ? formatDimension( + selectedMeasurement.length, + selectedMeasurement.width, + selectedMeasurement.height, + selectedMeasurement.dimensionType || selectedModel?.dimensionType + ) + : formatDimension( + selectedModel?.length, + selectedModel?.width, + selectedModel?.height, + selectedModel?.dimensionType + ); + const selectedPromotionPrice = + selectedMeasurement?.isConfigurePromotionPrice + ? formatMoney( + selectedMeasurement.promotionPrice, + selectedMeasurement.promotionCurrency || selectedMeasurement.currency || selectedModel?.currency + ) + : selectedModel?.isConfigurePromotionPrice + ? formatMoney(selectedModel.promotionPrice, selectedModel.promotionCurrency || selectedModel.currency) + : undefined; + const selectedWarehouses = selectedMeasurement?.warehouses || selectedModel?.warehouses || []; + + if (models.length === 0) { + return ( +
+ {l.noModels} +
+ ); + } + + return ( +
+
+
+

{l.summaryTitle}

+
+
+

{l.productPrice}

+

{getEffectivePriceLabel(models) || "-"}

+
+
+

{l.productWeight}

+

{getEffectiveWeightLabel(models) || l.varied}

+
+
+

{l.productDimension}

+

{getEffectiveDimensionLabel(models) || l.varied}

+
+
+

{l.structureTitle}

+

+ {models.length} {l.modelLabel.toLowerCase()} •{" "} + {models.reduce((total, model) => total + getModelMeasurements(model).length, 0)} {l.variants} +

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

{l.modelLabel}

+
+ {models.map((model, index) => { + const active = index === selectedModelIndex; + const measurementCount = getModelMeasurements(model).length; + const thumb = imgUrl(model.imageId || selectedImageId); + return ( + + ); + })} +
+
+ +
+

{l.measurementLabel}

+ {hasMeasurements ? ( +
+ {measurements.map((measurement, index) => { + const active = index === selectedMeasurementIndex; + return ( + + ); + })} +
+ ) : ( +
+ {l.noMeasurement} +
+ )} +
+ +
+

{l.selectedDetailTitle}

+ + + {selectedMeasurement ? ( + + ) : null} + + + + +
+ + {selectedWarehouses.length > 0 ? ( +
+

{l.stock}

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