feat: add Ina Trading portal flows and API integration

This commit is contained in:
Wira Basalamah
2026-04-24 05:19:05 +07:00
parent d98b4769f0
commit e08f2f9286
97 changed files with 18889 additions and 110 deletions

View File

@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
const categories = [
{
icon: "payments",
title: "Payment Protocol",
desc: "Financial clearing cycles, escrow rules, and institutional wire frameworks.",
},
{
icon: "local_shipping",
title: "Shipping & Logistics",
desc: "Global supply chain tracking, customs documentation, and freight insurance.",
},
{
icon: "verified_user",
title: "Account & Compliance",
desc: "KYC requirements, identity verification, and regional trading licenses.",
},
{
icon: "language",
title: "Global Trading Rules",
desc: "International trade laws, market restrictions, and tariff updates.",
},
];
const faqs = [
"How to verify my business identity?",
"What are the shipping limits for Europe?",
"How to contact a trade curator?",
];
export default function HelpPage() {
const [search, setSearch] = useState("");
return (
<div className="min-h-screen bg-surface">
{/* Hero */}
<section className="relative bg-surface-container-low py-20 px-8 md:px-16 overflow-hidden">
{/* Dot grid */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.05]"
style={{
backgroundImage: "radial-gradient(#5b403d 0.5px, transparent 0.5px)",
backgroundSize: "24px 24px",
}}
/>
{/* Decorative accent */}
<div className="absolute top-0 right-0 w-1/3 h-full opacity-10 pointer-events-none">
<div className="w-full h-full bg-primary rotate-12 translate-x-24 -translate-y-12" />
</div>
<div className="relative z-10 max-w-4xl">
<h1 className="text-5xl md:text-6xl font-black font-headline text-on-surface tracking-tighter leading-none mb-8">
How can we help <br />
<span className="text-primary">you today?</span>
</h1>
<div className="relative group max-w-2xl">
<span className="material-symbols-outlined absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-primary transition-colors">
search
</span>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full h-14 pl-14 pr-6 bg-white shadow-xl text-base font-medium placeholder:text-slate-400 focus:ring-2 focus:ring-primary/20 focus:outline-none rounded-xl border-none transition-all"
placeholder="Search for documentation, protocols, or support articles..."
type="text"
/>
</div>
</div>
</section>
{/* Popular Categories */}
<section className="px-8 md:px-16 py-14">
<div className="flex items-end justify-between mb-10">
<div>
<p className="text-[10px] font-bold text-primary uppercase tracking-[0.3em] mb-1">Resource Infrastructure</p>
<h2 className="text-2xl font-black font-headline tracking-tight uppercase">Popular Categories</h2>
</div>
<div className="h-[2px] flex-1 bg-surface-container ml-8 mb-1.5 hidden md:block" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{categories.map((cat) => (
<div
key={cat.title}
className="bg-white p-8 rounded-xl shadow-sm hover:-translate-y-1 hover:shadow-xl transition-all cursor-pointer group relative overflow-hidden border border-surface-container"
>
<div className="absolute top-0 left-0 w-1 h-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity rounded-l-xl" />
<span className="material-symbols-outlined text-4xl text-primary mb-5 block">{cat.icon}</span>
<h3 className="text-base font-black font-headline mb-2 uppercase tracking-tight">{cat.title}</h3>
<p className="text-sm text-slate-500 leading-relaxed">{cat.desc}</p>
</div>
))}
</div>
</section>
{/* FAQ + Contact Support */}
<section className="px-8 md:px-16 pb-16 grid grid-cols-1 lg:grid-cols-3 gap-10">
{/* FAQ */}
<div className="lg:col-span-2">
<h2 className="text-xl font-black font-headline tracking-tight uppercase mb-6">
Top Frequently Asked Questions
</h2>
<div className="space-y-3">
{faqs.map((q) => (
<div
key={q}
className="p-5 bg-surface-container-low rounded-xl flex justify-between items-center group hover:bg-surface-container transition-colors cursor-pointer"
>
<span className="font-bold text-on-surface">{q}</span>
<span className="material-symbols-outlined text-primary group-hover:translate-x-1 transition-transform">
arrow_forward
</span>
</div>
))}
</div>
</div>
{/* Contact Support */}
<div className="relative bg-primary rounded-2xl p-8 text-white flex flex-col justify-between overflow-hidden shadow-2xl shadow-primary/20 min-h-[320px]">
<div
className="absolute inset-0 pointer-events-none opacity-20"
style={{
backgroundImage: "radial-gradient(#fff 0.5px, transparent 0.5px)",
backgroundSize: "24px 24px",
}}
/>
<div className="relative z-10">
<h2 className="text-2xl font-black font-headline leading-tight uppercase mb-4">
Can&apos;t find what you&apos;re looking for?
</h2>
<p className="text-white/80 text-sm leading-relaxed mb-6">
Our expert trade curators are available 24/7 for institutional grade support and technical troubleshooting.
</p>
</div>
<div className="relative z-10 space-y-3">
<button className="w-full bg-white text-primary font-black py-3.5 rounded-xl flex items-center justify-center gap-2 hover:bg-surface-container-low transition-colors uppercase text-xs tracking-widest">
<span className="material-symbols-outlined text-base">chat_bubble</span>
Open a Ticket
</button>
<button className="w-full border-2 border-white/40 text-white font-black py-3.5 rounded-xl flex items-center justify-center gap-2 hover:bg-white/10 transition-colors uppercase text-xs tracking-widest">
<span className="material-symbols-outlined text-base">support_agent</span>
Contact Human Agent
</button>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-slate-50 border-t border-surface-container flex flex-col md:flex-row justify-between items-center gap-6 w-full py-10 px-10">
<div className="flex flex-col items-center md:items-start gap-2">
<div className="font-black text-slate-900 font-headline text-lg">Ina Trading</div>
<p className="text-[10px] font-medium uppercase tracking-widest text-slate-400 text-center md:text-left max-w-xs">
© 2024 Ina Trading Marketplace. All Rights Reserved.
</p>
</div>
<div className="flex flex-wrap justify-center gap-6">
{["Privacy Policy", "Compliance Framework", "Terms of Service", "Operational Status"].map((l) => (
<a
key={l}
href="#"
className="text-[10px] font-medium uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors"
>
{l}
</a>
))}
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,232 @@
"use client";
import { useLanguage } from "@/lib/i18n-context";
const recentOrders = [
{
id: "ORD-001",
product: "Titan Minimalist V2",
sku: "TM-9920",
customer: "Sarah Jenkins",
location: "London, UK",
date: "Oct 24, 2023",
amount: "$249.00",
status: "Processing",
statusColor: "text-primary bg-primary/10",
},
{
id: "ORD-002",
product: "Sonic Wave Pro",
sku: "SW-1021",
customer: "Marcus Thorne",
location: "Berlin, DE",
date: "Oct 23, 2023",
amount: "$499.00",
status: "Shipped",
statusColor: "text-secondary bg-secondary/10",
},
{
id: "ORD-003",
product: "Pulse Runner X",
sku: "PR-8821",
customer: "Elena Rodriguez",
location: "Madrid, ES",
date: "Oct 23, 2023",
amount: "$125.00",
status: "Delivered",
statusColor: "text-tertiary bg-tertiary/10",
},
];
const barHeights = [40, 65, 50, 85, 70, 60, 95, 45, 55, 80];
export default function DashboardPage() {
const { t } = useLanguage();
const d = t.dashboard.overview;
return (
<div className="p-8">
{/* Hero Title */}
<div className="mb-10">
<h1 className="text-5xl font-black font-headline tracking-tighter text-on-surface mb-2">
{d.title}
</h1>
<p className="text-on-surface-variant font-medium">{d.subtitle}</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10">
{/* Total Products */}
<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>
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
{d.totalProducts}
</p>
<h3 className="text-5xl font-black font-headline text-primary">1,284</h3>
<div className="flex items-center gap-2 mt-4 text-secondary font-bold text-sm">
<span className="material-symbols-outlined text-sm">trending_up</span>
<span>+12.5% {d.vsLastMonth}</span>
</div>
</div>
{/* Total Buyers */}
<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-secondary"></div>
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
{d.totalBuyers}
</p>
<h3 className="text-5xl font-black font-headline text-secondary">42,502</h3>
<div className="flex items-center gap-2 mt-4 text-tertiary font-bold text-sm">
<span className="material-symbols-outlined text-sm">group</span>
<span>{d.globalReach}</span>
</div>
</div>
{/* Refunds */}
<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-tertiary"></div>
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
{d.refunds}
</p>
<h3 className="text-5xl font-black font-headline text-tertiary">142</h3>
<div className="flex items-center gap-2 mt-4 text-error font-bold text-sm">
<span className="material-symbols-outlined text-sm">history</span>
<span>0.3% {d.returnRate}</span>
</div>
</div>
</div>
{/* Analytics Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-10">
{/* Orders Analytics Chart */}
<div className="lg:col-span-2 bg-surface-container-lowest p-8 rounded-xl magazine-shadow">
<div className="flex items-center justify-between mb-8">
<div>
<h4 className="text-xl font-black font-headline tracking-tight">{d.ordersAnalytics}</h4>
<p className="text-sm text-on-surface-variant font-medium">{d.ordersSubtitle}</p>
</div>
<select className="bg-surface-container-low border border-surface-container-high rounded-xl text-sm font-bold text-on-surface px-3 py-2 outline-none focus:border-primary">
<option>{d.last30Days}</option>
<option>{d.lastQuarter}</option>
</select>
</div>
{/* Bar Chart */}
<div className="h-64 flex items-end justify-between gap-2 px-2">
{barHeights.map((height, i) => (
<div
key={i}
className={`w-full rounded-t-lg transition-all ${
i === 4 ? "bg-primary" : "bg-surface-container hover:bg-primary/20"
}`}
style={{ height: `${height}%` }}
/>
))}
</div>
<div className="flex justify-between mt-4 px-2 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
<span>{d.wk} 1</span>
<span>{d.wk} 2</span>
<span>{d.wk} 3</span>
<span>{d.wk} 4</span>
</div>
</div>
{/* Earnings */}
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow flex flex-col">
<h4 className="text-xl font-black font-headline tracking-tight mb-6">{d.earnings}</h4>
{/* Donut Chart Placeholder */}
<div className="flex-1 flex items-center justify-center">
<div className="w-44 h-44 rounded-full border-[16px] border-surface-container flex items-center justify-center relative">
<div
className="absolute inset-[-16px] rounded-full border-[16px] border-primary"
style={{ clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 50%)" }}
/>
<div className="text-center">
<span className="block text-3xl font-black font-headline">$84.2k</span>
<span className="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
{d.grossRevenue}
</span>
</div>
</div>
</div>
{/* Legend */}
<div className="space-y-4 mt-6">
{[
{ color: "bg-primary", label: d.directSales, pct: "65%" },
{ color: "bg-secondary", label: d.retailPartners, pct: "25%" },
{ color: "bg-tertiary", label: d.affiliates, pct: "10%" },
].map((item) => (
<div key={item.label} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className={`w-3 h-3 ${item.color} rounded-full`}></span>
<span className="font-semibold">{item.label}</span>
</div>
<span className="font-bold text-on-surface-variant">{item.pct}</span>
</div>
))}
</div>
</div>
</div>
{/* Recent Orders Table */}
<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>
<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>
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-surface-container-low text-[10px] font-black text-on-surface-variant uppercase tracking-widest">
<th className="px-8 py-4">{d.productDetails}</th>
<th className="px-8 py-4">{d.customer}</th>
<th className="px-8 py-4">{d.transactionDate}</th>
<th className="px-8 py-4">{d.amount}</th>
<th className="px-8 py-4">{d.status}</th>
<th className="px-8 py-4 text-right">{d.action}</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-container">
{recentOrders.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">
<div className="w-12 h-12 rounded-xl bg-surface-container flex items-center justify-center">
<span className="material-symbols-outlined text-on-surface-variant">inventory_2</span>
</div>
<div>
<p className="font-bold text-sm">{order.product}</p>
<p className="text-xs text-on-surface-variant">SKU: {order.sku}</p>
</div>
</div>
</td>
<td className="px-8 py-5">
<p className="text-sm font-semibold">{order.customer}</p>
<p className="text-xs text-on-surface-variant">{order.location}</p>
</td>
<td className="px-8 py-5 text-sm font-medium text-on-surface-variant">{order.date}</td>
<td className="px-8 py-5 text-sm font-bold">{order.amount}</td>
<td className="px-8 py-5">
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tighter ${order.statusColor}`}>
{order.status}
</span>
</td>
<td className="px-8 py-5 text-right">
<button className="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors">
more_horiz
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,381 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { COUNTRIES } from "@/lib/countries";
export interface WarehouseFormState {
name: string;
address: string;
country: string;
province: string;
provinceId: string;
city: string;
cityId: string;
postalCode: string;
latitude: string;
longitude: string;
warehouseType: string;
}
export const defaultWarehouseForm: WarehouseFormState = {
name: "",
address: "",
country: "Indonesia",
province: "",
provinceId: "",
city: "",
cityId: "",
postalCode: "",
latitude: "",
longitude: "",
warehouseType: "INA",
};
interface Province { id: string; name: string; }
interface City { id: string; name: string; }
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function normalizeToken(t: string) {
if (!t) return "";
return t.startsWith("Bearer ") ? t : `Bearer ${t}`;
}
const inputCls =
"w-full bg-surface-container-low border-b-2 border-outline/30 focus:border-primary border-t-0 border-x-0 rounded-t-lg px-4 py-3 text-sm font-medium text-on-surface placeholder:text-slate-400 focus:ring-0 focus:outline-none transition-all";
const labelCls = "block text-xs font-black uppercase tracking-[0.15em] text-slate-500 mb-2";
interface WarehouseFormProps {
initialData?: WarehouseFormState;
pageTitle: string;
pageSubtitle: string;
submitLabel: string;
submittingLabel: string;
successMessage: string;
apiMethod: "POST" | "PUT";
apiUrl: string;
}
export function WarehouseForm({
initialData,
pageTitle,
pageSubtitle,
submitLabel,
submittingLabel,
successMessage,
apiMethod,
apiUrl,
}: WarehouseFormProps) {
const router = useRouter();
const [form, setForm] = useState<WarehouseFormState>(initialData ?? defaultWarehouseForm);
const [provinces, setProvinces] = useState<Province[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [loadingProvinces, setLoadingProvinces] = useState(false);
const [loadingCities, setLoadingCities] = useState(false);
const [saving, setSaving] = useState(false);
const [savingPhase, setSavingPhase] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const isIndonesia = form.country === "Indonesia";
useEffect(() => {
if (initialData) setForm(initialData);
}, [initialData]);
useEffect(() => {
if (!isIndonesia) {
setProvinces([]);
setCities([]);
return;
}
setLoadingProvinces(true);
fetch("/api/locations/provinces", { headers: { "x-auth-token": normalizeToken(getToken()) } })
.then((r) => r.json())
.then((d) => setProvinces(Array.isArray(d?.rows) ? d.rows : []))
.catch(() => setProvinces([]))
.finally(() => setLoadingProvinces(false));
}, [isIndonesia]);
useEffect(() => {
if (!isIndonesia || !form.provinceId) {
setCities([]);
return;
}
setLoadingCities(true);
fetch(`/api/locations/cities?provinceId=${form.provinceId}`, {
headers: { "x-auth-token": normalizeToken(getToken()) },
})
.then((r) => r.json())
.then((d) => setCities(Array.isArray(d?.rows) ? d.rows : []))
.catch(() => setCities([]))
.finally(() => setLoadingCities(false));
}, [isIndonesia, form.provinceId]);
function update(patch: Partial<WarehouseFormState>) {
setForm((prev) => ({ ...prev, ...patch }));
}
async function handleSave() {
if (!form.address.trim()) { setError("Alamat wajib diisi"); return; }
setSaving(true);
setSavingPhase("Menyimpan perubahan...");
setError("");
const payload: Record<string, string | number | null> = {
name: form.name || null,
address: form.address,
country: form.country || null,
province: form.province || null,
city: form.city || null,
postalCode: form.postalCode || null,
latitude: form.latitude ? parseFloat(form.latitude) : null,
longitude: form.longitude ? parseFloat(form.longitude) : null,
warehouseType: form.warehouseType || null,
};
try {
const token = normalizeToken(getToken());
const res = await fetch(apiUrl, {
method: apiMethod,
headers: { "Content-Type": "application/json", "x-auth-token": token },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data?.responseDesc || data?.error || "Gagal menyimpan warehouse");
return;
}
setSuccess(true);
setTimeout(() => router.push("/dashboard/warehouse"), 1500);
} catch {
setError("Gagal terhubung ke server");
} finally {
setSaving(false);
setSavingPhase("");
}
}
return (
<div className="p-8 min-h-screen">
{/* Page Header */}
<div className="flex items-center justify-between mb-8">
<div>
<span className="text-primary font-bold tracking-widest text-xs uppercase">Management Dashboard</span>
<h1 className="text-4xl font-extrabold tracking-tight text-on-surface mt-1">{pageTitle}</h1>
<p className="text-slate-500 text-sm mt-1">{pageSubtitle}</p>
</div>
<button
type="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>
{/* Form Card */}
<div className="bg-white rounded-2xl border-l-4 border-primary shadow-sm p-8">
<h2 className="text-2xl font-bold tracking-tight mb-8">Facility Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Name */}
<div className="md:col-span-2">
<label className={labelCls}>Nama Warehouse</label>
<input
value={form.name}
onChange={(e) => update({ name: e.target.value })}
placeholder="Contoh: Gudang Utama Jakarta..."
className={inputCls}
/>
</div>
{/* Address */}
<div className="md:col-span-2">
<label className={labelCls}>Alamat *</label>
<input
value={form.address}
onChange={(e) => update({ address: e.target.value })}
placeholder="Jl. Nama Jalan No. ..."
className={inputCls}
/>
</div>
{/* Country */}
<div className="md:col-span-2">
<label className={labelCls}>Negara</label>
<select
value={form.country}
onChange={(e) =>
update({ country: e.target.value, province: "", provinceId: "", city: "", cityId: "" })
}
className={inputCls}
>
<option value="">Pilih negara...</option>
{COUNTRIES.map((c) => (
<option key={c.code} value={c.name}>{c.name}</option>
))}
</select>
</div>
{/* Province */}
<div>
<label className={labelCls}>Provinsi / State</label>
{isIndonesia ? (
<select
value={form.provinceId}
onChange={(e) => {
const selected = provinces.find((p) => p.id === e.target.value);
update({ provinceId: e.target.value, province: selected?.name || "", city: "", cityId: "" });
}}
disabled={loadingProvinces}
className={inputCls}
>
<option value="">
{loadingProvinces ? "Memuat provinsi..." : "Pilih provinsi..."}
</option>
{provinces.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : (
<input
value={form.province}
onChange={(e) => update({ province: e.target.value })}
placeholder="Nama provinsi / state..."
className={inputCls}
/>
)}
</div>
{/* City */}
<div>
<label className={labelCls}>Kota</label>
{isIndonesia ? (
<select
value={form.cityId}
onChange={(e) => {
const selected = cities.find((c) => c.id === e.target.value);
update({ cityId: e.target.value, city: selected?.name || "" });
}}
disabled={!form.provinceId || loadingCities}
className={inputCls}
>
<option value="">
{!form.provinceId ? "Pilih provinsi dulu..." : loadingCities ? "Memuat kota..." : "Pilih kota..."}
</option>
{cities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
) : (
<input
value={form.city}
onChange={(e) => update({ city: e.target.value })}
placeholder="Nama kota..."
className={inputCls}
/>
)}
</div>
{/* Postal Code */}
<div>
<label className={labelCls}>Kode Pos</label>
<input
value={form.postalCode}
onChange={(e) => update({ postalCode: e.target.value })}
placeholder="Contoh: 13920"
className={inputCls}
/>
</div>
{/* Warehouse Type */}
<div>
<label className={labelCls}>Tipe Warehouse</label>
<select
value={form.warehouseType}
onChange={(e) => update({ warehouseType: e.target.value })}
className={inputCls}
>
<option value="INA">INA</option>
<option value="Other">Other</option>
</select>
</div>
{/* Lat / Lng */}
<div>
<label className={labelCls}>Latitude</label>
<input
type="number"
step="any"
value={form.latitude}
onChange={(e) => update({ latitude: e.target.value })}
placeholder="Contoh: -6.1891"
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>Longitude</label>
<input
type="number"
step="any"
value={form.longitude}
onChange={(e) => update({ longitude: e.target.value })}
placeholder="Contoh: 106.9247"
className={inputCls}
/>
</div>
</div>
{/* Status messages + buttons */}
<div className="mt-10 space-y-4">
{success && (
<div className="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>
{successMessage}
</div>
)}
{error && (
<div className="p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
<span className="material-symbols-outlined">error</span>
{error}
</div>
)}
<div className="flex flex-col items-end gap-2">
{savingPhase && (
<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>
{savingPhase}
</p>
)}
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
disabled={saving}
className="px-6 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm uppercase tracking-[0.15em] hover:bg-surface-container-low transition-colors disabled:opacity-40"
>
Batal
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || success}
className="bg-gradient-to-br from-primary to-primary-container text-white px-10 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all disabled:opacity-60 disabled:hover:scale-100"
>
{saving ? (savingPhase || submittingLabel) : submitLabel}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { WarehouseForm, WarehouseFormState } from "../../WarehouseForm";
function getCachedWarehouse(warehouseId: string): WarehouseFormState | null {
if (typeof window === "undefined" || !warehouseId) return null;
const cached = sessionStorage.getItem("editWarehouseCache");
if (!cached) return null;
try {
const raw = JSON.parse(cached) as Record<string, unknown>;
if (raw.id !== warehouseId) return null;
sessionStorage.removeItem("editWarehouseCache");
return {
name: typeof raw.name === "string" ? raw.name : "",
address: typeof raw.address === "string" ? raw.address : "",
country: typeof raw.country === "string" ? raw.country : "Indonesia",
province: typeof raw.province === "string" ? raw.province : "",
provinceId: "",
city: typeof raw.city === "string" ? raw.city : "",
cityId: "",
postalCode: typeof raw.postalCode === "string" ? raw.postalCode : "",
latitude: raw.latitude != null ? String(raw.latitude) : "",
longitude: raw.longitude != null ? String(raw.longitude) : "",
warehouseType:
typeof raw.warehouseType === "string" ? raw.warehouseType : "INA",
};
} catch {
return null;
}
}
export default function EditWarehousePage() {
const params = useParams<{ warehouseId: string }>();
const router = useRouter();
const [initialData] = useState<WarehouseFormState | null>(() =>
getCachedWarehouse(params.warehouseId)
);
const loadError = initialData
? ""
: "Data tidak tersedia. Kembali ke daftar warehouse dan klik Edit lagi.";
if (loadError) {
return (
<div className="p-8 flex flex-col items-center justify-center h-64 gap-4">
<span className="material-symbols-outlined text-4xl text-error">error</span>
<p className="text-sm font-semibold text-error">{loadError}</p>
<button onClick={() => router.back()} className="text-sm font-bold text-primary hover:underline">Kembali</button>
</div>
);
}
if (!initialData) {
return (
<div className="p-8 flex items-center justify-center h-64">
<span className="material-symbols-outlined text-4xl text-slate-300 animate-spin">progress_activity</span>
</div>
);
}
return (
<WarehouseForm
initialData={initialData}
pageTitle="Edit Warehouse"
pageSubtitle="Perbarui informasi gudang"
submitLabel="Simpan Perubahan"
submittingLabel="Menyimpan..."
successMessage="Warehouse berhasil diperbarui! Mengalihkan..."
apiMethod="PUT"
apiUrl={`/api/warehouses/${params.warehouseId}`}
/>
);
}

View File

@ -0,0 +1,17 @@
"use client";
import { WarehouseForm } from "../WarehouseForm";
export default function NewWarehousePage() {
return (
<WarehouseForm
pageTitle="Tambah Warehouse"
pageSubtitle="Daftarkan gudang baru ke dalam sistem"
submitLabel="Simpan Warehouse"
submittingLabel="Menyimpan..."
successMessage="Warehouse berhasil ditambahkan! Mengalihkan..."
apiMethod="POST"
apiUrl="/api/warehouses"
/>
);
}

View File

@ -0,0 +1,311 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface WarehouseRow {
id: string;
name: string | null;
address: string | null;
city: string | null;
province: string | null;
country: string | null;
postalCode: string | null;
latitude: number | null;
longitude: number | null;
warehouseType: string | null;
}
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function normalizeToken(t: string) {
if (!t) return "";
return t.startsWith("Bearer ") ? t : `Bearer ${t}`;
}
function typeBadge(type: string | null) {
if (type === "INA") return "bg-primary-fixed text-on-primary-fixed-variant";
if (type === "Other") return "bg-secondary-fixed text-on-secondary-fixed-variant";
return "bg-slate-100 text-slate-500";
}
export default function WarehousePage() {
const router = useRouter();
const [rows, setRows] = useState<WarehouseRow[]>([]);
const [filtered, setFiltered] = useState<WarehouseRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [totalItem, setTotalItem] = useState(0);
const [totalPage, setTotalPage] = useState(1);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const pageSize = 20;
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
async function load() {
setLoading(true);
setError("");
try {
const token = normalizeToken(getToken());
const res = await fetch(`/api/products/warehouses?page=${page + 1}&size=${pageSize}`, {
headers: { "x-auth-token": token },
});
const data = await res.json();
const list: WarehouseRow[] = Array.isArray(data?.rows) ? data.rows : [];
setRows(list);
setFiltered(list);
setTotalItem(data?.totalItem ?? 0);
setTotalPage(data?.totalPage ?? 1);
} catch {
setError("Gagal memuat data warehouse");
} finally {
setLoading(false);
}
}
useEffect(() => {
const q = search.toLowerCase();
setFiltered(
rows.filter(
(r) =>
(r.name || "").toLowerCase().includes(q) ||
(r.address || "").toLowerCase().includes(q) ||
(r.city || "").toLowerCase().includes(q) ||
(r.province || "").toLowerCase().includes(q) ||
(r.country || "").toLowerCase().includes(q)
)
);
}, [search, rows]);
async function handleDelete(id: string) {
setDeleting(true);
try {
const token = normalizeToken(getToken());
const res = await fetch(`/api/warehouses/${id}`, {
method: "DELETE",
headers: { "x-auth-token": token },
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d?.responseDesc || "Gagal menghapus warehouse");
return;
}
setDeleteId(null);
load();
} catch {
alert("Gagal terhubung ke server");
} finally {
setDeleting(false);
}
}
const startEntry = page * pageSize + 1;
const endEntry = Math.min((page + 1) * pageSize, totalItem);
return (
<div className="p-8 min-h-screen">
{/* Delete Confirmation Modal */}
{deleteId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-sm w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-error-container flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-error">delete</span>
</div>
<h3 className="text-lg font-black text-on-surface">Hapus Warehouse?</h3>
</div>
<p className="text-sm text-slate-500 mb-6">
Tindakan ini tidak bisa dibatalkan. Warehouse akan dihapus secara permanen.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteId(null)}
disabled={deleting}
className="px-5 py-2.5 rounded-xl border border-outline-variant/30 text-sm font-black text-on-surface hover:bg-surface-container-low transition-colors disabled:opacity-40"
>
Batal
</button>
<button
onClick={() => handleDelete(deleteId)}
disabled={deleting}
className="px-5 py-2.5 rounded-xl bg-error text-white text-sm font-black hover:opacity-90 transition-opacity disabled:opacity-60"
>
{deleting ? "Menghapus..." : "Hapus"}
</button>
</div>
</div>
</div>
)}
{/* Header */}
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<span className="text-primary font-bold tracking-widest text-xs uppercase">Management Dashboard</span>
<h1 className="text-4xl font-extrabold tracking-tight text-on-surface mt-1">Warehouse</h1>
<p className="text-slate-500 font-semibold mt-1">Kelola gudang dan lokasi penyimpanan produk</p>
</div>
<div className="flex items-center gap-3">
<div className="bg-white border border-zinc-100 h-12 px-4 flex items-center rounded-xl shadow-sm">
<span className="material-symbols-outlined text-slate-400 mr-2 text-lg">search</span>
<input
className="bg-transparent border-none focus:ring-0 text-sm font-medium w-48 lg:w-64 placeholder:text-slate-300 outline-none"
placeholder="Cari berdasarkan lokasi..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Link
href="/dashboard/warehouse/new"
className="h-12 px-6 flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white rounded-xl font-bold text-sm hover:opacity-90 transition-opacity shadow-lg shadow-primary/20"
>
<span className="material-symbols-outlined text-[20px]">add</span>
<span>Tambah Warehouse</span>
</Link>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-2xl overflow-hidden border border-zinc-100 shadow-sm">
{loading ? (
<div className="p-16 text-center text-slate-400">
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
<p className="text-sm font-medium">Memuat data...</p>
</div>
) : error ? (
<div className="p-16 text-center text-error">
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
<p className="text-sm font-medium">{error}</p>
</div>
) : filtered.length === 0 ? (
<div className="p-16 text-center text-slate-400">
<span className="material-symbols-outlined text-4xl mb-4 block">warehouse</span>
<p className="text-sm font-medium">Belum ada warehouse</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-zinc-50/50 border-b border-zinc-100">
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Warehouse</th>
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Alamat</th>
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Kode Pos</th>
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Tipe</th>
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400 text-right">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{filtered.map((item) => (
<tr key={item.id} className="hover:bg-zinc-50/40 transition-colors group">
{/* Warehouse name */}
<td className="px-8 py-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary">warehouse</span>
</div>
<div>
<p className="font-black text-on-surface tracking-tight">
{item.name || <span className="text-slate-400 font-medium italic">Tanpa nama</span>}
</p>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
{item.city && item.province ? `${item.city}, ${item.province}` : item.city || item.province || "—"}
</p>
</div>
</div>
</td>
{/* Address */}
<td className="px-8 py-6">
<p className="text-sm font-semibold text-on-surface">{item.address || "—"}</p>
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{item.country || ""}</p>
</td>
{/* Postal code */}
<td className="px-8 py-6">
<span className="text-sm font-semibold text-on-surface">{item.postalCode || "—"}</span>
</td>
{/* Type */}
<td className="px-8 py-6">
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${typeBadge(item.warehouseType)}`}>
{item.warehouseType || "—"}
</span>
</td>
{/* Actions */}
<td className="px-8 py-6 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
sessionStorage.setItem("editWarehouseCache", JSON.stringify(item));
router.push(`/dashboard/warehouse/${item.id}/edit`);
}}
className="w-10 h-10 flex items-center justify-center text-slate-400 hover:text-on-surface hover:bg-zinc-100 rounded-xl transition-all"
title="Edit"
>
<span className="material-symbols-outlined text-[20px]">edit</span>
</button>
<button
onClick={() => setDeleteId(item.id)}
className="w-10 h-10 flex items-center justify-center text-red-300 hover:text-primary hover:bg-red-50 rounded-xl transition-all"
title="Hapus"
>
<span className="material-symbols-outlined text-[20px]">delete</span>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{!loading && totalItem > 0 && (
<div className="px-8 py-5 bg-zinc-50/50 flex items-center justify-between border-t border-zinc-50">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
Menampilkan {startEntry}{endEntry} dari {totalItem} warehouse
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="w-10 h-10 flex items-center justify-center border border-zinc-200 rounded-xl text-slate-400 hover:bg-white transition-all disabled:opacity-40"
>
<span className="material-symbols-outlined text-sm">chevron_left</span>
</button>
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
<button
key={i}
onClick={() => setPage(i)}
className={`w-10 h-10 flex items-center justify-center rounded-xl text-xs font-black transition-all ${
i === page ? "bg-primary text-white shadow-lg shadow-primary/20" : "border border-zinc-200 text-on-surface hover:bg-white"
}`}
>
{i + 1}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
disabled={page >= totalPage - 1}
className="w-10 h-10 flex items-center justify-center border border-zinc-200 rounded-xl text-slate-400 hover:bg-white transition-all disabled:opacity-40"
>
<span className="material-symbols-outlined text-sm">chevron_right</span>
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,155 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { AppIcon } from "@/components/app-icon";
import { LanguageToggle } from "@/components/language-toggle";
import { useLanguage } from "@/lib/i18n-context";
import { ProductSubmenuNav } from "@/components/product-submenu-nav";
const navItems = [
{ href: "/dashboard", icon: "dashboard", label: "Dashboard" },
{ href: "/products", icon: "inventory_2", label: "Product" },
{ href: "/dashboard/inventory", icon: "inventory", label: "Inventory" },
{ href: "/dashboard/warehouse", icon: "warehouse", label: "Warehouse" },
{ href: "/dashboard/orders", icon: "shopping_cart", label: "Orders" },
{ href: "/dashboard/invoice", icon: "receipt", label: "Invoice" },
{ href: "/dashboard/customers", icon: "groups", label: "Customers" },
{ href: "/dashboard/marketing", icon: "campaign", label: "Marketing" },
{ href: "/dashboard/finance", icon: "payments", label: "Finance" },
{ href: "/dashboard/reports", icon: "assessment", label: "Reports" },
{ href: "/dashboard/analytics", icon: "analytics", label: "Analytics" },
{ href: "/dashboard/shop", icon: "storefront", label: "Shop" },
{ href: "/dashboard/reviews", icon: "reviews", label: "Reviews" },
{ href: "/settings", icon: "storefront", label: "Settings" },
];
const visibleNavLabels = new Set([
"Dashboard",
"Product",
"Warehouse",
// "Orders", // hidden temporarily
// "Invoice", // hidden temporarily
"Settings",
]);
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { t } = useLanguage();
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("role");
sessionStorage.removeItem("token");
sessionStorage.removeItem("role");
router.push("/login");
};
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">
<AppIcon name="search" className="h-5 w-5 text-on-surface-variant" />
<input
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"
/>
</div>
</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">
<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">
<AppIcon name="chat" className="h-5 w-5" />
</button>
</div>
</header>
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-full w-72 bg-surface-container-lowest flex flex-col py-6 border-r border-surface-container z-40">
{/* Sidebar Header */}
<div className="mt-12 px-6 mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-on-primary">
<AppIcon name="analytics" className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-black text-primary font-headline leading-none uppercase">{t.dashboard.layout.editorial}</h2>
<p className="text-[10px] text-on-surface-variant font-bold tracking-widest uppercase mt-0.5">{t.dashboard.layout.globalExportHub}</p>
</div>
</div>
</div>
{/* Nav Items */}
<nav className="flex-1 overflow-y-auto px-3 space-y-0.5">
{navItems.map((item) => {
const isActive =
item.href === "/products"
? pathname === "/products" || pathname.startsWith("/products/")
: item.href === "/dashboard/warehouse"
? pathname === "/dashboard/warehouse" || pathname.startsWith("/dashboard/warehouse/")
: pathname === item.href;
const isVisible = visibleNavLabels.has(item.label);
const label = t.dashboard.layout.nav[item.label as keyof typeof t.dashboard.layout.nav] ?? item.label;
const isProduct = item.label === "Product";
return (
<div key={item.href} className={isVisible ? "block" : "hidden"}>
<Link
href={item.href}
className={`flex items-center gap-4 px-4 py-3 text-sm font-semibold font-headline rounded-xl transition-all ${
isActive
? "bg-white text-primary border-l-4 border-primary shadow-sm ml-0 rounded-l-none"
: "text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform"
}`}
>
<AppIcon name={item.icon as Parameters<typeof AppIcon>[0]["name"]} className="h-5 w-5" />
<span>{label}</span>
</Link>
{isProduct && isActive ? <ProductSubmenuNav /> : null}
</div>
);
})}
<div className="pt-4 mt-4 border-t border-surface-container">
<Link
href="/dashboard/help"
className="flex items-center gap-4 px-4 py-3 text-sm font-semibold font-headline text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform rounded-xl"
>
<AppIcon name="help" className="h-5 w-5" />
<span>{t.dashboard.layout.helpCenter}</span>
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-4 px-4 py-3 text-sm font-semibold font-headline text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform rounded-xl"
>
<AppIcon name="logout" className="h-5 w-5" />
<span>{t.common.logout}</span>
</button>
</div>
</nav>
</aside>
{/* Main Content */}
<main className="ml-72 pt-16 min-h-screen">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,516 @@
"use client";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { useLanguage } from "@/lib/i18n-context";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
interface ProductWarehouse {
id?: string;
stock?: number;
}
interface ProductMeasurement {
measurementType?: string;
measurementValue?: 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[];
}
interface ProductModel {
warehouses?: ProductWarehouse[];
productMeasurements?: ProductMeasurement[];
}
interface ProductImage {
sequence?: number;
imageId?: string;
}
interface ProductInfoItem {
paramName: string;
paramValue: string;
}
interface ProductCategory {
name?: string;
}
interface ProductSubCategory {
id?: string;
name?: string;
category?: ProductCategory;
}
interface ProductDetail {
name?: string;
state?: string;
subCategory?: ProductSubCategory;
isPreOrder?: boolean;
isNew?: boolean;
isEligibleToExport?: boolean;
preOrderDay?: string | number;
description?: string;
imageId?: string;
productImages?: ProductImage[];
productModels?: ProductModel[];
productKeyWords?: string[];
productFeatures?: string[];
productInformations?: ProductInfoItem[];
categoryInformations?: ProductInfoItem[];
complianceInformation?: {
countryOfOrigin?: string;
safetyWarning?: string;
isDangerousGoodRegulation?: boolean;
};
warrantyInformation?: {
type?: string;
duration?: string | number;
durationType?: string;
};
}
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function SectionHeader({ step, title }: { step: string; title: string }) {
return (
<div className="flex items-center gap-4 mb-6">
<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">
{step}
</div>
<h2 className="text-xl font-black font-headline tracking-tight text-on-surface">{title}</h2>
</div>
);
}
function Row({ label, value }: { label: string; value?: string | number | boolean | null }) {
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 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 isNonEmptyString(value: string | undefined): value is string {
return typeof value === "string" && value.length > 0;
}
function ToggleBadge({ label, value }: { label: string; value: boolean }) {
return (
<div className="flex items-center justify-between p-4 rounded-xl bg-surface-container-low">
<span className="text-sm font-bold text-on-surface">{label}</span>
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${value ? "bg-primary/10 text-primary" : "bg-surface-container text-outline"}`}>
{value ? "Ya" : "Tidak"}
</span>
</div>
);
}
function ProductDetailPageInner() {
const { t } = useLanguage();
const d = t.dashboard.productDetail;
const params = useParams<{ productId: string }>();
const searchParams = useSearchParams();
const isDraft = searchParams.get("draft") === "1";
const [product, setProduct] = useState<ProductDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const errorLoadText = d.errorLoad;
useEffect(() => {
if (!params.productId) return;
const url = `/api/products/${params.productId}${isDraft ? "?draft=1" : ""}`;
fetch(url, { headers: { "x-auth-token": getToken() } })
.then((r) => r.json())
.then((j) => {
if (!j) throw new Error("No data");
setProduct(j?.data || j);
})
.catch(() => setError(errorLoadText))
.finally(() => setLoading(false));
}, [errorLoadText, params.productId, isDraft]);
if (loading) {
return (
<div className="w-full px-6 md:px-10 py-8 flex items-center justify-center py-32">
<p className="text-sm font-semibold text-on-surface-variant">{d.loading}</p>
</div>
);
}
if (error || !product) {
return (
<div className="w-full px-6 md:px-10 py-8">
<div className="rounded-xl border border-error/20 bg-error-container p-6 text-sm font-semibold text-on-error-container">
{error || d.notFound}
</div>
</div>
);
}
const models: ProductModel[] = Array.isArray(product.productModels)
? product.productModels
: [];
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords.filter(Boolean) : [];
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
const productInfos = Array.isArray(product.productInformations) ? product.productInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
const categoryInfos = Array.isArray(product.categoryInformations) ? product.categoryInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
const allImages: string[] = [
...(product.imageId ? [product.imageId] : []),
...(Array.isArray(product.productImages)
? product.productImages
.sort(
(a: ProductImage, b: ProductImage) =>
(a.sequence ?? 0) - (b.sequence ?? 0)
)
.map((img: ProductImage) => img.imageId)
.filter(isNonEmptyString)
: []),
];
return (
<div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8">
{/* Page Header */}
<div className="mb-2">
<nav className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline mb-4">
<span>{d.breadcrumbProducts}</span>
<span className="material-symbols-outlined text-sm">chevron_right</span>
<span>{d.breadcrumbEditor}</span>
<span className="material-symbols-outlined text-sm">chevron_right</span>
<span className="text-primary">{d.title}</span>
</nav>
<div className="flex items-start justify-between">
<div>
<h1 className="text-4xl font-black font-headline tracking-tighter text-on-surface">{product.name || d.title}</h1>
<p className="mt-2 text-on-surface-variant font-medium">{product.state || "DRAFT"}</p>
</div>
<Link
href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`}
className="editorial-gradient text-white px-6 py-3 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-md shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
{d.editProduct}
</Link>
</div>
</div>
{/* ── Section 01: Basic Details (Category) ───────────────────────────── */}
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
<SectionHeader step="01" title={d.section01} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-surface-container-low">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.mainCategory}</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
</div>
<div className="p-4 rounded-xl bg-surface-container-low">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.subCategory}</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.name || product.subCategory?.id || "—"}</p>
</div>
</div>
</div>
{/* ── Section 02: Description ────────────────────────────────────────── */}
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
{/* Left */}
<div className="xl:col-span-7 bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-6">
<SectionHeader step="02" title={d.section02} />
{/* Name */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.officialName}</p>
<p className="text-base font-semibold text-on-surface">{product.name || "—"}</p>
</div>
{/* Toggles */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ToggleBadge label={d.preOrder} value={!!product.isPreOrder} />
<ToggleBadge label={d.brandNew} value={product.isNew !== false} />
</div>
{product.isPreOrder && (
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.preOrderDay}</p>
<p className="text-sm font-semibold text-on-surface">{product.preOrderDay || "—"}</p>
</div>
)}
{/* Keywords */}
{keywords.length > 0 && (
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.keywords}</p>
<div className="flex flex-wrap gap-2">
{keywords.map((k: string) => (
<span key={k} className="rounded-full bg-primary/5 px-3 py-2 text-xs font-black text-primary border border-primary/10">{k}</span>
))}
</div>
</div>
)}
{/* Description */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.narrative}</p>
<p className="text-sm font-medium text-on-surface leading-relaxed whitespace-pre-line">{product.description || "—"}</p>
</div>
{/* Features */}
{features.length > 0 && (
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.features}</p>
<div className="space-y-2">
{features.map((f: string, i: number) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low">
<span className="material-symbols-outlined text-outline/30 text-lg">drag_indicator</span>
<p className="flex-1 text-sm font-semibold text-on-surface">{f}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Right: Visual Identity */}
<div className="xl:col-span-5 bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.visualIdentity}</p>
{allImages.length === 0 ? (
<div className="aspect-square rounded-2xl bg-surface-container-low flex items-center justify-center">
<span className="material-symbols-outlined text-5xl text-outline/30">image</span>
</div>
) : (
<div className="space-y-3">
{allImages.map((imgId, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
<div className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${API_BASE}/api/v1.0/file/image/${imgId}`}
alt={i === 0 ? "Main Image" : `Gallery ${i}`}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-outline">
{i === 0 ? d.mainImage : `${d.gallery} ${i}`}
</p>
<p className="text-xs font-semibold text-on-surface truncate mt-0.5">{t.common.uploaded}</p>
</div>
</div>
))}
</div>
)}
<div className="mt-2 p-4 rounded-2xl bg-primary/5 border border-primary/10 flex gap-3">
<span className="material-symbols-outlined text-primary text-[20px] shrink-0">info</span>
<p className="text-[11px] font-bold leading-relaxed text-on-surface-variant">
{allImages.length} {d.imagesAvailable}
</p>
</div>
</div>
</div>
{/* ── 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 className="font-mono">{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 className="font-mono">{w.id?.slice(0, 8)}...</span>
<span className="font-bold">{w.stock ?? 0} unit</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* ── Section 04: General Info ──────────────────────────────────────── */}
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-8">
<SectionHeader step="04" title={d.section04} />
{productInfos.length > 0 && (
<div>
<p className="text-sm font-black text-on-surface mb-3">{d.productInfo}</p>
{productInfos.map((item: { paramName: string; paramValue: string }, i: number) => (
<Row key={i} label={item.paramName} value={item.paramValue} />
))}
</div>
)}
{categoryInfos.length > 0 && (
<>
{productInfos.length > 0 && <div className="border-t border-surface-container" />}
<div>
<p className="text-sm font-black text-on-surface mb-3">{d.categoryInfo}</p>
{categoryInfos.map((item: { paramName: string; paramValue: string }, i: number) => (
<Row key={i} label={item.paramName} value={item.paramValue} />
))}
</div>
</>
)}
{product.complianceInformation && (
<>
<div className="border-t border-surface-container" />
<div>
<p className="text-sm font-black text-on-surface mb-3">{d.compliance}</p>
<Row label={d.countryOfOrigin} value={product.complianceInformation.countryOfOrigin} />
<Row label={d.safetyWarning} value={product.complianceInformation.safetyWarning} />
<Row label={d.dangerousGoods} value={product.complianceInformation.isDangerousGoodRegulation} />
</div>
</>
)}
{product.warrantyInformation && (
<>
<div className="border-t border-surface-container" />
<div>
<p className="text-sm font-black text-on-surface mb-3">{d.warranty}</p>
<Row label="Type" value={product.warrantyInformation.type} />
<Row label={d.warrantyDuration} value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
</div>
</>
)}
<div className="border-t border-surface-container" />
<div>
<p className="text-sm font-black text-on-surface mb-3">{d.export}</p>
<ToggleBadge label={d.eligibleToExport} value={!!product.isEligibleToExport} />
</div>
</div>
{/* ── Fixed Bottom Footer ───────────────────────────────────────────── */}
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
<div className="w-full flex items-center justify-between">
<div className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">
{d.modeReadOnly}
</div>
<div className="flex items-center gap-4">
<Link
href="/products"
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
>
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
Kembali
</Link>
<Link
href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
Edit Produk
</Link>
</div>
</div>
</div>
</div>
);
}
export default function ProductDetailPage() {
return (
<Suspense>
<ProductDetailPageInner />
</Suspense>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,224 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context";
interface CategoryOption {
id: string;
name: string;
subCategoryAttributes?: Array<{ id: string; paramName: string }>;
}
function getToken() {
if (typeof window === "undefined") {
return "";
}
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
export default function ProductCategoryPage() {
const router = useRouter();
const { draft, setDraft } = useProductDraft();
const { submit, submitting } = useProductSubmit();
const { t } = useLanguage();
const c = t.dashboard.productNew.category;
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [subCategories, setSubCategories] = useState<CategoryOption[]>([]);
const [loading, setLoading] = useState(true);
const [loadingSubcategories, setLoadingSubcategories] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
async function loadCategories() {
setLoading(true);
setError("");
try {
const res = await fetch("/api/products/categories?size=100", {
headers: { "x-auth-token": getToken() },
});
const result = await res.json();
if (!res.ok || (result?.responseCode && result.responseCode !== "0000")) {
throw new Error(result?.responseDesc || c.errorLoadCat);
}
setCategories(Array.isArray(result?.rows) ? result.rows : []);
} catch (err) {
setError(err instanceof Error ? err.message : c.errorLoadCat);
} finally {
setLoading(false);
}
}
loadCategories();
}, []);
useEffect(() => {
async function loadSubCategories() {
if (!draft.categoryId) {
setSubCategories([]);
return;
}
setLoadingSubcategories(true);
setError("");
try {
const res = await fetch(`/api/products/subcategories/${draft.categoryId}?size=100`, {
headers: { "x-auth-token": getToken() },
});
const result = await res.json();
if (!res.ok || (result?.responseCode && result.responseCode !== "0000")) {
throw new Error(result?.responseDesc || c.errorLoadSub);
}
setSubCategories(Array.isArray(result?.rows) ? result.rows : []);
} catch (err) {
setError(
err instanceof Error ? err.message : c.errorLoadSub
);
} finally {
setLoadingSubcategories(false);
}
}
loadSubCategories();
}, [draft.categoryId]);
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="lg:col-span-7">
<div className="bg-surface-container-lowest p-8 md:p-10 rounded-2xl border border-outline-variant/10 shadow-sm">
<div className="space-y-8">
<div>
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-primary mb-3">
{c.mainCategory}
</label>
<select
value={draft.categoryId}
onChange={(ev) => {
const selected = categories.find((cat) => cat.id === ev.target.value);
setDraft((prev) => ({
...prev,
categoryId: ev.target.value,
categoryName: selected?.name || "",
subCategoryId: "",
subCategoryName: "",
categoryInformations: [],
}));
}}
disabled={loading}
className="w-full rounded-xl bg-surface-container-low border-none px-5 py-5 font-semibold text-on-surface focus:ring-2 focus:ring-primary/10 disabled:opacity-60"
>
<option value="">
{loading ? c.loadingCategories : c.selectMain}
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-3">
{c.subCategory}
</label>
<select
value={draft.subCategoryId}
onChange={(ev) => {
const selected = subCategories.find((sc) => sc.id === ev.target.value);
setDraft((prev) => ({
...prev,
subCategoryId: ev.target.value,
subCategoryName: selected?.name || "",
subCategoryAttributes: selected?.subCategoryAttributes || [],
categoryInformations: [],
}));
}}
disabled={!draft.categoryId || loadingSubcategories}
className="w-full rounded-xl bg-surface-container-low border-none px-5 py-5 font-semibold text-on-surface focus:ring-2 focus:ring-primary/10 disabled:opacity-60"
>
<option value="">
{!draft.categoryId
? c.awaitingMain
: loadingSubcategories
? c.loadingSubcategories
: c.selectSub}
</option>
{subCategories.map((subCategory) => (
<option key={subCategory.id} value={subCategory.id}>
{subCategory.name}
</option>
))}
</select>
</div>
{error ? (
<div className="rounded-xl border border-error/20 bg-error-container p-4 text-sm font-semibold text-on-error-container">
{error}
</div>
) : null}
</div>
</div>
</div>
<div className="lg:col-span-5">
<div className="rounded-2xl overflow-hidden border border-outline-variant/10 bg-primary text-white p-8 min-h-[240px] flex flex-col justify-end">
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-white/70 mb-3">
{c.title}
</p>
<p className="text-3xl font-black font-headline tracking-tight leading-tight">
{c.subtitle}
</p>
</div>
</div>
{/* Fixed Bottom Footer */}
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-8">
<button
type="button"
disabled={submitting}
onClick={async () => { try { await submit("DRAFT"); router.push("/products"); } catch {} }}
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
>
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
{submitting ? t.dashboard.productNew.category.saveDraft : c.saveDraft}
</button>
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{c.autoSaved}</span>
</div>
</div>
<div className="flex items-center gap-4">
<Link
href="/products"
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
{c.cancel}
</Link>
<button
type="button"
onClick={() => router.push("/products/new/details")}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
{c.next}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,432 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context";
const MAX_IMAGES = 8;
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function ImageSlotItem({
fileId,
slotNumber,
onUploaded,
onRemove,
}: {
fileId: string;
slotNumber: number;
onUploaded: (fileId: string) => void;
onRemove: () => void;
}) {
const { t } = useLanguage();
const pe = t.dashboard.productEdit;
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
setUploading(true);
setError("");
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal");
setPreviewUrl("");
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
const hasImage = fileId || previewUrl;
return (
<div className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
<div
className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden cursor-pointer"
onClick={() => inputRef.current?.click()}
>
{previewUrl ? (
<img src={previewUrl} alt="" className="w-full h-full object-cover" />
) : (
<span
className="material-symbols-outlined text-2xl"
style={{
fontVariationSettings: hasImage ? "'FILL' 1" : "'FILL' 0",
color: hasImage ? "var(--color-primary)" : "var(--color-outline)",
}}
>
image
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">
Image {slotNumber}
</p>
<p className="text-xs font-semibold text-on-surface truncate mt-0.5">
{hasImage ? pe.uploaded : pe.noImage}
</p>
{error ? <p className="text-[10px] text-error mt-0.5">{error}</p> : null}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="px-3 py-1.5 rounded-lg bg-primary text-white text-[10px] font-black uppercase tracking-wider disabled:opacity-60"
>
{uploading ? "..." : hasImage ? pe.changeImage : "Upload"}
</button>
<button
type="button"
onClick={onRemove}
className="w-7 h-7 rounded-lg flex items-center justify-center text-error hover:bg-error-container transition-colors"
>
<span className="material-symbols-outlined text-base">delete</span>
</button>
</div>
<input ref={inputRef} type="file" accept="image/*" onChange={handleChange} className="hidden" />
</div>
);
}
export default function ProductDetailsPage() {
const router = useRouter();
const { draft, setDraft } = useProductDraft();
const { submit, submitting } = useProductSubmit();
const { t } = useLanguage();
const d = t.dashboard.productNew.details;
const [keywordInput, setKeywordInput] = useState("");
// slotsCount tracks how many image slots to show (starts from existing draft state)
const [slotsCount, setSlotsCount] = useState(() => {
return (draft.imageId ? 1 : 0) + draft.productImages.length;
});
function getSlotFileId(i: number): string {
if (i === 0) return draft.imageId;
return draft.productImages[i - 1] || "";
}
function handleImageUploaded(i: number, fileId: string) {
if (i === 0) {
setDraft((prev) => ({ ...prev, imageId: fileId }));
} else {
setDraft((prev) => {
const updated = [...prev.productImages];
while (updated.length < i) updated.push("");
updated[i - 1] = fileId;
return { ...prev, productImages: updated };
});
}
}
function handleImageRemove(i: number) {
if (i === 0) {
const [first = "", ...rest] = draft.productImages;
setDraft((prev) => ({ ...prev, imageId: first, productImages: rest }));
} else {
setDraft((prev) => {
const updated = [...prev.productImages];
updated.splice(i - 1, 1);
return { ...prev, productImages: updated };
});
}
setSlotsCount((c) => Math.max(0, c - 1));
}
function addImageSlot() {
if (slotsCount >= MAX_IMAGES) return;
const newIndex = slotsCount; // 0-based index of the new slot
if (newIndex >= 1) {
// Adding a gallery slot — reserve the space in productImages
setDraft((prev) => ({ ...prev, productImages: [...prev.productImages, ""] }));
}
// If newIndex === 0, it's the main image slot — imageId is already "" in draft
setSlotsCount((c) => c + 1);
}
function addKeyword() {
const value = keywordInput.trim();
if (!value) return;
setDraft((prev) => ({
...prev,
keywords: prev.keywords.includes(value) ? prev.keywords : [...prev.keywords, value],
}));
setKeywordInput("");
}
function updateFeature(index: number, value: string) {
setDraft((prev) => ({
...prev,
features: prev.features.map((f, i) => (i === index ? value : f)),
}));
}
function removeFeature(index: number) {
setDraft((prev) => ({
...prev,
features: prev.features.filter((_, i) => i !== index),
}));
}
return (
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
<div className="xl:col-span-7 space-y-8">
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-6">
<div>
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
{d.officialName}
</label>
<input
value={draft.name}
onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))}
placeholder={d.officialNamePlaceholder}
className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-1 flex items-center justify-between p-4 rounded-xl bg-surface-container-low">
<div>
<p className="text-sm font-bold">{d.preOrder}</p>
<p className="text-[11px] text-on-surface-variant">{d.preOrderSub}</p>
</div>
<input
type="checkbox"
checked={draft.isPreOrder}
onChange={(e) => setDraft((prev) => ({ ...prev, isPreOrder: e.target.checked }))}
className="h-5 w-5 rounded border-outline-variant"
/>
</div>
<div className="md:col-span-1 flex items-center justify-between p-4 rounded-xl bg-surface-container-low">
<div>
<p className="text-sm font-bold">{d.brandNew}</p>
<p className="text-[11px] text-on-surface-variant">{d.brandNewSub}</p>
</div>
<input
type="checkbox"
checked={draft.isNew}
onChange={(e) => setDraft((prev) => ({ ...prev, isNew: e.target.checked }))}
className="h-5 w-5 rounded border-outline-variant"
/>
</div>
<div>
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
{d.preOrderDay}
</label>
<input
value={draft.preOrderDay}
onChange={(e) => setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }))}
placeholder="14"
className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
{d.narrative}
</label>
<textarea
value={draft.description}
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
rows={6}
placeholder={d.narrativePlaceholder}
className="w-full bg-surface-container-low border-none rounded-xl p-5 font-medium focus:ring-2 focus:ring-primary/10"
/>
</div>
<div>
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
{d.keywords}
</label>
<div className="flex gap-3">
<input
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addKeyword();
}
}}
placeholder={d.addKeyword}
className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
/>
<button
type="button"
onClick={addKeyword}
className="px-5 py-3 rounded-xl bg-primary text-white font-black text-sm uppercase tracking-[0.12em]"
>
{d.addKeywordBtn}
</button>
</div>
{draft.keywords.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{draft.keywords.map((keyword) => (
<button
key={keyword}
type="button"
onClick={() =>
setDraft((prev) => ({
...prev,
keywords: prev.keywords.filter((k) => k !== keyword),
}))
}
className="rounded-full bg-primary/5 px-3 py-2 text-xs font-black text-primary"
>
{keyword} ×
</button>
))}
</div>
)}
</div>
</div>
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline">
{d.features}
</label>
<button
type="button"
onClick={() => setDraft((prev) => ({ ...prev, features: [...prev.features, ""] }))}
className="text-sm font-black text-primary"
>
{d.addFeature}
</button>
</div>
{draft.features.length === 0 && (
<p className="text-sm text-on-surface-variant font-medium py-2">
{d.noFeatures}
</p>
)}
{draft.features.map((feature, index) => (
<div key={index} className="flex gap-2 items-center">
<input
value={feature}
onChange={(e) => updateFeature(index, e.target.value)}
placeholder="e.g. Premium finishing"
className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
/>
<button
type="button"
onClick={() => removeFeature(index)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-base">close</span>
</button>
</div>
))}
</div>
</div>
<div className="xl:col-span-5 space-y-8">
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline block">
{d.visualIdentity}
</label>
<span className="text-[10px] font-bold text-outline">
{slotsCount} / {MAX_IMAGES}
</span>
</div>
{slotsCount === 0 && (
<p className="text-sm text-on-surface-variant font-medium py-2">
{d.noImages}
</p>
)}
<div className="space-y-2">
{Array.from({ length: slotsCount }, (_, i) => (
<ImageSlotItem
key={i}
fileId={getSlotFileId(i)}
slotNumber={i + 1}
onUploaded={(fileId) => handleImageUploaded(i, fileId)}
onRemove={() => handleImageRemove(i)}
/>
))}
</div>
{slotsCount < MAX_IMAGES && (
<button
type="button"
onClick={addImageSlot}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl border-2 border-dashed border-outline-variant/40 text-on-surface-variant hover:border-primary hover:text-primary transition-colors text-sm font-bold"
>
<span className="material-symbols-outlined text-lg">add</span>
{d.addImage}
</button>
)}
</div>
</div>
{/* Fixed Bottom Footer */}
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-8">
<button
type="button"
disabled={submitting}
onClick={async () => { try { await submit("DRAFT"); router.push("/products"); } catch {} }}
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
>
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
{submitting ? d.saveDraft : d.saveDraft}
</button>
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{d.autoSaved}</span>
</div>
</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.push("/products/new/category")}
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
>
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
{d.back}
</button>
<button
type="button"
onClick={() => router.push("/products/new/pricing")}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
{d.next}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,141 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { ProductDraftProvider, useProductDraft } from "@/lib/product-draft";
import { useLanguage } from "@/lib/i18n-context";
const stepHrefs = [
"/products/new/category",
"/products/new/details",
"/products/new/pricing",
"/products/new/specifications",
"/products/new/review",
];
const stepIndexes = ["01", "02", "03", "04", "05"];
function ProductWizardLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { resetDraft } = useProductDraft();
const { t } = useLanguage();
const n = t.dashboard.productNew;
const steps = stepHrefs.map((href, i) => ({
href,
label: n.layout.stepLabels[i],
index: stepIndexes[i],
}));
function handleCancel() {
resetDraft();
router.push("/products");
}
return (
<div className="min-h-screen bg-surface">
<div className="w-full px-6 md:px-10 py-8 pb-36">
<div className="mb-10">
<nav className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline mb-4">
<span>{n.layout.breadcrumbProducts}</span>
<span className="material-symbols-outlined text-sm">
chevron_right
</span>
<span>{n.layout.breadcrumbEditor}</span>
<span className="material-symbols-outlined text-sm">
chevron_right
</span>
<span className="text-primary">{n.layout.breadcrumbNew}</span>
</nav>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-4xl md:text-5xl font-black font-headline tracking-tighter text-on-surface">
{n.layout.pageTitle}
</h1>
<p className="mt-3 max-w-2xl text-on-surface-variant font-medium">
{n.layout.pageSubtitle}
</p>
</div>
<button
type="button"
onClick={handleCancel}
className="text-sm font-black uppercase tracking-[0.18em] text-outline hover:text-primary transition-colors"
>
{n.layout.cancel}
</button>
</div>
</div>
<div className="mb-10 overflow-x-auto">
<div className="min-w-[920px] bg-surface-container-lowest rounded-2xl border border-outline-variant/10 p-2 flex items-center justify-between gap-2">
{steps.map((step) => {
const isActive = pathname === step.href;
const isDone =
steps.findIndex((item) => item.href === pathname) >
steps.findIndex((item) => item.href === step.href);
return (
<div
key={step.href}
className={`flex-1 flex items-center justify-center gap-3 px-4 py-4 rounded-xl transition-all ${
isActive
? "editorial-gradient shadow-lg shadow-primary/20"
: isDone
? "bg-tertiary/10"
: "bg-transparent"
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-black ${
isActive
? "bg-white text-primary"
: isDone
? "bg-tertiary text-white"
: "bg-surface-container-low text-outline"
}`}
>
{isDone ? (
<span className="material-symbols-outlined text-sm">
check
</span>
) : (
step.index
)}
</div>
<span
className={`text-xs font-black uppercase tracking-[0.18em] whitespace-nowrap ${
isActive
? "text-white"
: isDone
? "text-tertiary"
: "text-outline"
}`}
>
{step.label}
</span>
</div>
);
})}
</div>
</div>
{children}
</div>
</div>
);
}
export default function ProductWizardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProductDraftProvider>
<ProductWizardLayoutInner>{children}</ProductWizardLayoutInner>
</ProductDraftProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,407 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context";
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function toNumber(value: string) {
const normalized = value.replace(/\./g, "").replace(/,/g, ".");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatIDR(value: string) {
const num = toNumber(value);
if (!num) return "-";
return new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0 }).format(num);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-outline mb-3 pb-2 border-b border-surface-container">
{children}
</h3>
);
}
function Row({ label, value, yes, no }: { label: string; value?: string | number | boolean | null; yes: string; no: string }) {
if (value === "" || value === undefined || value === null) return null;
const display =
typeof value === "boolean"
? value ? yes : no
: String(value);
return (
<div className="flex justify-between gap-4 py-1.5 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 Badge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-bold">
{children}
</span>
);
}
export default function ProductReviewPage() {
const router = useRouter();
const { draft } = useProductDraft();
const { submit, submitting, error, errorLog, setError } = useProductSubmit();
const { t } = useLanguage();
const r = t.dashboard.productNew.review;
const [errorLogCopied, setErrorLogCopied] = useState(false);
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
useEffect(() => {
async function loadWarehouses() {
try {
const res = await fetch("/api/products/warehouses?size=100", {
headers: { "x-auth-token": getToken() },
});
const result = await res.json();
const rows: Array<{ id: string; name: string; address: string }> =
Array.isArray(result?.rows) ? result.rows :
Array.isArray(result?.data) ? result.data : [];
const map: Record<string, string> = {};
for (const w of rows) {
map[w.id] = w.name || w.address || w.id;
}
setWarehouseMap(map);
} catch {
// silently ignore — fallback to ID display
}
}
loadWarehouses();
}, []);
async function handleSaveDraft() {
setErrorLogCopied(false);
try {
await submit("DRAFT");
router.push("/products");
} catch {
// error is set by the hook
}
}
async function handleSubmitForReview() {
setErrorLogCopied(false);
try {
await submit("PUBLISHED");
router.push("/products/new/submitted");
} catch {
// error is set by the hook
}
}
return (
<div className="max-w-3xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-black font-headline tracking-tight">{r.title}</h2>
<p className="text-sm text-on-surface-variant mt-1">
{r.subtitle}
</p>
</div>
</div>
{/* Category */}
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
<SectionTitle>01 · {r.section01}</SectionTitle>
<Row label={r.category} value={draft.categoryName || draft.categoryId || "-"} yes={r.yes} no={r.no} />
<Row label={r.subCategory} value={draft.subCategoryName || draft.subCategoryId || "-"} yes={r.yes} no={r.no} />
</div>
{/* Details */}
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
<SectionTitle>02 · {r.section02}</SectionTitle>
<Row label={r.productName} value={draft.name || "-"} yes={r.yes} no={r.no} />
<Row label={r.description} value={draft.description || "-"} yes={r.yes} no={r.no} />
<Row label={r.preOrder} value={draft.isPreOrder} yes={r.yes} no={r.no} />
{draft.isPreOrder && <Row label={r.preOrderDay} value={draft.preOrderDay} yes={r.yes} no={r.no} />}
<Row label={r.brandNew} value={draft.isNew} yes={r.yes} no={r.no} />
<Row label={r.eligibleToExport} value={draft.isEligibleToExport} yes={r.yes} no={r.no} />
{draft.keywords.filter(Boolean).length > 0 && (
<div className="mt-3">
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.keywords}</p>
<div className="flex flex-wrap gap-2">
{draft.keywords.filter(Boolean).map((k) => <Badge key={k}>{k}</Badge>)}
</div>
</div>
)}
{draft.features.filter(Boolean).length > 0 && (
<div className="mt-3">
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.features}</p>
<ul className="space-y-1">
{draft.features.filter(Boolean).map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-on-surface">
<span className="material-symbols-outlined text-primary text-sm mt-0.5">check</span>
{f}
</li>
))}
</ul>
</div>
)}
<div className="mt-3 flex flex-wrap gap-3">
{draft.imageId && (
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
<span className="material-symbols-outlined text-primary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
{r.mainImage}
</div>
)}
{draft.productImages.filter(Boolean).length > 0 && (
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
<span className="material-symbols-outlined text-secondary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>photo_library</span>
{draft.productImages.filter(Boolean).length} {r.gallery}
</div>
)}
</div>
</div>
{/* Pricing & Models */}
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
<SectionTitle>03 · {r.section03} ({draft.models.length} {r.model})</SectionTitle>
<div className="space-y-5">
{draft.models.map((model, idx) => (
<div key={model.id} className="rounded-xl border border-outline-variant/10 bg-surface-container-low p-4 space-y-3">
{/* Model header */}
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white text-[10px] font-black">{idx + 1}</div>
<p className="text-sm font-black text-on-surface">{model.name || `Model ${idx + 1}`}</p>
{model.sku && <span className="text-[10px] text-outline font-bold">SKU: {model.sku}</span>}
</div>
{/* Model core info */}
<div className="grid grid-cols-2 gap-x-6">
<Row label={r.price} value={`${model.currency || "IDR"} ${formatIDR(model.price)}`} yes={r.yes} no={r.no} />
<Row label={`${r.weight} (${model.weightType || "G"})`} value={model.weight ? `${model.weight}` : undefined} yes={r.yes} no={r.no} />
<Row label={`${r.dimensions} (${model.dimensionType || "CM"})`} value={[model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
{model.hasPromotion && <Row label={r.promoPrice} value={`${model.promotionCurrency || model.currency || "IDR"} ${formatIDR(model.promotionPrice)}`} yes={r.yes} no={r.no} />}
{model.hasPromotion && model.promotionStartDate && (
<Row label={r.promoPeriod} value={`${model.promotionStartDate}${model.promotionEndDate}`} yes={r.yes} no={r.no} />
)}
<Row label={`${r.packagingWeight} (${model.packagingWeightType || "G"})`} value={model.packagingWeight || undefined} yes={r.yes} no={r.no} />
<Row label={`${r.packagingDimensions} (${model.packagingDimensionType || "CM"})`} value={[model.packagingLength, model.packagingWidth, model.packagingHeight].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
</div>
{/* Warehouse stock */}
{model.warehouses.filter((w) => w.id).length > 0 && (
<div>
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-1">{r.warehouseStock}</p>
{model.warehouses.filter((w) => w.id).map((w, wi) => (
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
<span>{warehouseMap[w.id] || w.name || `${w.id.slice(0, 8)}...`}</span>
<span className="font-bold">{w.stock} {r.unit}</span>
</div>
))}
</div>
)}
{/* Measurements / Variants */}
{model.measurements.length > 0 && (
<div>
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-2">
{r.measurements} ({model.measurements.length})
</p>
<div className="space-y-2">
{model.measurements.map((ms, mi) => (
<div key={ms.id} className="rounded-lg bg-surface-container-lowest border border-outline-variant/10 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-black text-primary uppercase tracking-wider">
{ms.measurementType || `${r.variantLabel} ${mi + 1}`}
</span>
{ms.measurementValue && (
<span className="text-[10px] font-bold text-outline bg-surface-container px-2 py-0.5 rounded-full">
{ms.measurementValue}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-6">
<Row label={r.price} value={`${ms.currency || "IDR"} ${formatIDR(ms.price)}`} yes={r.yes} no={r.no} />
<Row label={`${r.weight} (${ms.weightType || "G"})`} value={ms.weight || undefined} yes={r.yes} no={r.no} />
<Row label={`${r.dimensions} (${ms.dimensionType || "CM"})`} value={[ms.length, ms.width, ms.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
{ms.hasPromotion && <Row label={r.promoPrice} value={`${ms.promotionCurrency || ms.currency || "IDR"} ${formatIDR(ms.promotionPrice)}`} yes={r.yes} no={r.no} />}
{ms.hasPromotion && ms.promotionStartDate && (
<Row label={r.promoPeriod} value={`${ms.promotionStartDate}${ms.promotionEndDate}`} yes={r.yes} no={r.no} />
)}
<Row label={`${r.packagingWeight} (${ms.packagingWeightType || "G"})`} value={ms.packagingWeight || undefined} yes={r.yes} no={r.no} />
</div>
{ms.warehouses.filter((w) => w.id).length > 0 && (
<div className="mt-1.5">
<p className="text-[9px] text-outline font-bold uppercase tracking-widest mb-1">{r.stock}</p>
{ms.warehouses.filter((w) => w.id).map((w, wi) => (
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
<span>{warehouseMap[w.id] || w.name || `${w.id.slice(0, 8)}...`}</span>
<span className="font-bold">{w.stock} {r.unit}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* Specifications */}
{(draft.productInformations.filter((i) => i.paramName && i.paramValue).length > 0 ||
draft.categoryInformations.filter((i) => i.paramName && i.paramValue).length > 0) && (
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
<SectionTitle>04 · {r.section04}</SectionTitle>
{draft.productInformations.filter((i) => i.paramName && i.paramValue).length > 0 && (
<div className="mb-4">
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.productInfo}</p>
{draft.productInformations.filter((i) => i.paramName && i.paramValue).map((item, i) => (
<Row key={i} label={item.paramName} value={item.paramValue} yes={r.yes} no={r.no} />
))}
</div>
)}
{draft.categoryInformations.filter((i) => i.paramName && i.paramValue).length > 0 && (
<div className="mb-4">
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.categoryInfo}</p>
{draft.categoryInformations.filter((i) => i.paramName && i.paramValue).map((item, i) => (
<Row key={i} label={item.paramName} value={item.paramValue} yes={r.yes} no={r.no} />
))}
</div>
)}
<div className="mb-4">
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.compliance}</p>
<Row label={r.countryOfOrigin} value={draft.complianceInformation.countryOfOrigin} yes={r.yes} no={r.no} />
<Row label={r.safetyWarning} value={draft.complianceInformation.safetyWarning} yes={r.yes} no={r.no} />
<Row label={r.dangerousGoods} value={draft.complianceInformation.isDangerousGoodRegulation} yes={r.yes} no={r.no} />
{draft.complianceInformation.fileId && (
<div className="flex justify-between gap-4 py-1.5 text-sm">
<span className="text-on-surface-variant font-medium flex-shrink-0">{r.msds}</span>
<span className="flex items-center gap-1.5 font-semibold text-primary text-right">
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>description</span>
<span className="font-mono text-xs truncate max-w-[200px]">{draft.complianceInformation.fileId}</span>
</span>
</div>
)}
</div>
{(draft.warrantyInformation.type || draft.warrantyInformation.duration) && (
<div>
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.warranty}</p>
<Row label={r.warrantyType} value={draft.warrantyInformation.type} yes={r.yes} no={r.no} />
<Row
label={r.warrantyDuration}
value={draft.warrantyInformation.duration
? `${draft.warrantyInformation.duration} ${draft.warrantyInformation.durationType}`
: undefined}
yes={r.yes}
no={r.no}
/>
</div>
)}
</div>
)}
{/* Supporting Documents */}
{(draft.productFiles ?? []).length > 0 && (
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
<SectionTitle>05 · {r.section05}</SectionTitle>
<div className="space-y-2">
{(draft.productFiles ?? []).map((file) => (
<div key={file.id} className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
<span className="material-symbols-outlined text-primary text-[18px]" style={{ fontVariationSettings: "'FILL' 1" }}>description</span>
<span className="flex-1 text-sm font-semibold text-on-surface truncate">{file.name}</span>
</div>
))}
</div>
</div>
)}
{/* Fixed Bottom Footer */}
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
<div className="w-full space-y-3">
{error && (
<div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
<p>{error}</p>
</div>
{errorLog && (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(errorLog, null, 2));
setErrorLogCopied(true);
setTimeout(() => setErrorLogCopied(false), 2000);
}}
className="flex-shrink-0 flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest bg-error/10 hover:bg-error/20 text-error border border-error/30 rounded-lg px-3 py-1.5 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{errorLogCopied ? "check" : "content_copy"}
</span>
{errorLogCopied ? r.copied : r.copyErrorLog}
</button>
)}
</div>
</div>
)}
{submitting && (
<div className="flex items-center gap-2 text-xs text-on-surface-variant font-semibold">
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
{r.saving}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<button
type="button"
onClick={handleSaveDraft}
disabled={submitting}
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
>
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
{r.saveDraft}
</button>
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{r.autoSaved}</span>
</div>
</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.push("/products/new/specifications")}
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
>
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
{r.back}
</button>
<button
type="button"
onClick={handleSubmitForReview}
disabled={submitting}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0 flex items-center gap-2"
>
{submitting ? r.submitting : r.submit}
{!submitting && <span className="material-symbols-outlined text-[18px]">send</span>}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,541 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useProductDraft } from "@/lib/product-draft";
import { useProductSubmit } from "@/lib/use-product-submit";
import { useLanguage } from "@/lib/i18n-context";
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
const GENERAL_INFO_FIELDS = [
{ key: "Brand", label: "Brand", required: true, colSpan: 2, placeholder: "e.g. Sterling Elite" },
{ key: "Manufacturer", label: "Manufacturer", required: false, colSpan: 1, placeholder: "Official Entity" },
{ key: "Color", label: "Color", required: false, colSpan: 1, placeholder: "e.g. Obsidian" },
{ key: "Material", label: "Material", required: false, colSpan: 2, placeholder: "e.g. Aerospace Grade Aluminum" },
] as const;
function useFileUpload(onSuccess: (fileId: string, fileName: string) => void) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
async function upload(file: File) {
setUploading(true);
setError("");
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
const id = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id;
if (!id) throw new Error("File id tidak ditemukan");
onSuccess(id, file.name);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal");
} finally {
setUploading(false);
}
}
return { upload, uploading, error };
}
const inputClass =
"w-full bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-medium text-on-surface focus:ring-2 focus:ring-primary/10 focus:bg-surface-container-lowest transition-all outline-none placeholder:text-on-surface-variant/40";
const labelClass =
"block text-[10px] font-black uppercase tracking-widest text-on-surface/60 mb-1.5";
const sectionClass =
"bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6";
const sectionIconClass =
"w-10 h-10 rounded-xl bg-primary/5 flex items-center justify-center text-primary flex-shrink-0";
export default function ProductSpecificationsPage() {
const router = useRouter();
const { draft, setDraft } = useProductDraft();
const { submit, submitting } = useProductSubmit();
const { t } = useLanguage();
const s = t.dashboard.productNew.specifications;
const msdsInputRef = useRef<HTMLInputElement>(null);
const docInputRef = useRef<HTMLInputElement>(null);
const [msdsName, setMsdsName] = useState("");
// --- productInformations helpers ---
function getProductInfo(key: string) {
return draft.productInformations.find((i) => i.paramName === key)?.paramValue ?? "";
}
function setProductInfo(key: string, value: string) {
setDraft((prev) => {
const exists = prev.productInformations.some((i) => i.paramName === key);
if (exists) {
return {
...prev,
productInformations: prev.productInformations.map((i) =>
i.paramName === key ? { ...i, paramValue: value } : i
),
};
}
return {
...prev,
productInformations: [...prev.productInformations, { paramName: key, paramValue: value }],
};
});
}
// --- categoryInformations helpers ---
function getCategoryInfo(paramName: string) {
return draft.categoryInformations.find((i) => i.paramName === paramName)?.paramValue ?? "";
}
function setCategoryInfo(paramName: string, value: string) {
setDraft((prev) => {
const exists = prev.categoryInformations.some((i) => i.paramName === paramName);
if (exists) {
return {
...prev,
categoryInformations: prev.categoryInformations.map((i) =>
i.paramName === paramName ? { ...i, paramValue: value } : i
),
};
}
return {
...prev,
categoryInformations: [...prev.categoryInformations, { paramName: paramName, paramValue: value }],
};
});
}
// --- MSDS Upload ---
const { upload: uploadMsds, uploading: uploadingMsds, error: msdsError } = useFileUpload(
(id, name) => {
setDraft((prev) => ({
...prev,
complianceInformation: { ...prev.complianceInformation, fileId: id },
}));
setMsdsName(name);
}
);
// --- Supporting Docs Upload ---
const { upload: uploadDoc, uploading: uploadingDoc, error: docError } = useFileUpload(
(id, name) => {
setDraft((prev) => ({
...prev,
productFiles: [...(prev.productFiles ?? []), { id, name }],
}));
}
);
function removeDoc(id: string) {
setDraft((prev) => ({ ...prev, productFiles: (prev.productFiles ?? []).filter((f) => f.id !== id) }));
}
const subCategoryAttributes = draft.subCategoryAttributes ?? [];
return (
<div className="space-y-5 pb-32">
{/* 1. General Information */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-5">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">description</span>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.generalInfo}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{GENERAL_INFO_FIELDS.map((field) => (
<div key={field.key} className={field.colSpan === 2 ? "md:col-span-2" : ""}>
<label className={labelClass}>
{field.label}
{field.required && <span className="text-error ml-0.5">*</span>}
</label>
<input
value={getProductInfo(field.key)}
onChange={(e) => setProductInfo(field.key, e.target.value)}
placeholder={field.placeholder}
required={field.required}
className={inputClass}
/>
</div>
))}
</div>
</section>
{/* 2. Category Information */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-5">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">category</span>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.categoryInfo}</h3>
</div>
{subCategoryAttributes.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{subCategoryAttributes.map((attr) => (
<div key={attr.id}>
<label className={labelClass}>{attr.paramName}</label>
<input
value={getCategoryInfo(attr.paramName)}
onChange={(e) => setCategoryInfo(attr.paramName, e.target.value)}
placeholder={`e.g. ${attr.paramName}...`}
className={inputClass}
/>
</div>
))}
</div>
) : (
<div className="flex items-center gap-3 py-3 text-sm text-on-surface-variant">
<span className="material-symbols-outlined text-outline text-[20px]">info</span>
<span>{s.awaitingCategory}</span>
</div>
)}
</section>
{/* 3. Compliance */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-5">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">verified_user</span>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.compliance}</h3>
</div>
<div className="space-y-4">
<div>
<label className={labelClass}>{s.safetyWarning}</label>
<textarea
value={draft.complianceInformation.safetyWarning}
onChange={(e) =>
setDraft((prev) => ({
...prev,
complianceInformation: { ...prev.complianceInformation, safetyWarning: e.target.value },
}))
}
rows={3}
placeholder="Enter all safety precautions..."
className="w-full bg-surface-container-low rounded-xl border-none px-4 py-3 text-sm font-medium text-on-surface focus:ring-2 focus:ring-primary/10 resize-none transition-all outline-none placeholder:text-on-surface-variant/40"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
<div>
<label className={labelClass}>{s.countryOfOrigin}</label>
<input
value={draft.complianceInformation.countryOfOrigin}
onChange={(e) =>
setDraft((prev) => ({
...prev,
complianceInformation: { ...prev.complianceInformation, countryOfOrigin: e.target.value },
}))
}
placeholder="e.g. Indonesia"
className={inputClass}
/>
</div>
<div>
<p className={labelClass}>{s.dangerousGoods}</p>
<div className="flex bg-surface-container-low p-1 rounded-xl">
<button
type="button"
onClick={() =>
setDraft((prev) => ({
...prev,
complianceInformation: { ...prev.complianceInformation, isDangerousGoodRegulation: true },
}))
}
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
draft.complianceInformation.isDangerousGoodRegulation
? "bg-white shadow-sm text-primary"
: "text-on-surface-variant hover:text-on-surface"
}`}
>
{s.yes}
</button>
<button
type="button"
onClick={() =>
setDraft((prev) => ({
...prev,
complianceInformation: { ...prev.complianceInformation, isDangerousGoodRegulation: false },
}))
}
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
!draft.complianceInformation.isDangerousGoodRegulation
? "bg-white shadow-sm text-primary"
: "text-on-surface-variant hover:text-on-surface"
}`}
>
{s.no}
</button>
</div>
</div>
</div>
{/* MSDS Upload */}
<div>
<p className={labelClass}>{s.msds}</p>
<input
ref={msdsInputRef}
type="file"
accept=".pdf,.docx,.doc"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadMsds(f);
e.target.value = "";
}}
/>
{draft.complianceInformation.fileId ? (
<div className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
<span className="material-symbols-outlined text-primary text-[20px]">description</span>
<span className="flex-1 text-sm font-semibold text-on-surface truncate">
{msdsName || draft.complianceInformation.fileId}
</span>
<button
type="button"
onClick={() => {
setDraft((prev) => ({
...prev,
complianceInformation: { ...prev.complianceInformation, fileId: "" },
}));
setMsdsName("");
}}
className="text-outline hover:text-error transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
) : (
<button
type="button"
onClick={() => msdsInputRef.current?.click()}
disabled={uploadingMsds}
className="w-full border border-dashed border-outline-variant rounded-xl px-4 py-6 flex flex-col items-center justify-center gap-2 bg-surface-container-low/20 hover:bg-surface-container-low/60 transition-all disabled:opacity-60"
>
{uploadingMsds ? (
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined text-primary text-2xl">upload_file</span>
)}
<div className="text-center">
<p className="text-sm font-bold text-on-surface">
{uploadingMsds ? t.dashboard.productEdit.uploading : s.msds}
</p>
<p className="text-[10px] text-on-surface-variant">PDF, DOCX up to 15MB</p>
</div>
</button>
)}
{msdsError && <p className="text-[11px] text-error mt-1.5 font-semibold">{msdsError}</p>}
</div>
</div>
</section>
{/* 4. Warranty */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-5">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">verified</span>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.warranty}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>{s.warrantyType}</label>
<input
value={draft.warrantyInformation.type}
onChange={(e) =>
setDraft((prev) => ({
...prev,
warrantyInformation: { ...prev.warrantyInformation, type: e.target.value },
}))
}
placeholder="e.g. Global Manufacturers Warranty"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{s.warrantyDuration}</label>
<div className="flex gap-3">
<input
value={draft.warrantyInformation.duration}
onChange={(e) =>
setDraft((prev) => ({
...prev,
warrantyInformation: { ...prev.warrantyInformation, duration: e.target.value },
}))
}
placeholder="0"
type="number"
min="0"
className="w-24 bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-medium focus:ring-2 focus:ring-primary/10 outline-none"
/>
<select
value={draft.warrantyInformation.durationType}
onChange={(e) =>
setDraft((prev) => ({
...prev,
warrantyInformation: { ...prev.warrantyInformation, durationType: e.target.value },
}))
}
className="flex-1 bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 outline-none"
>
<option value="DAY">Days</option>
<option value="MONTH">Months</option>
<option value="YEAR">Years</option>
</select>
</div>
</div>
</div>
</section>
{/* 5. International Export */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-5">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">public</span>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">International Export</h3>
</div>
<div className="flex items-center justify-between bg-surface-container-low/50 p-4 rounded-xl">
<div>
<p className="text-sm font-bold text-on-surface">Eligible for export?</p>
<p className="text-[10px] text-on-surface-variant mt-0.5">Check if the item can be shipped globally</p>
</div>
<div className="flex bg-surface-container-low p-1 rounded-xl w-36 shrink-0">
<button
type="button"
onClick={() => setDraft((prev) => ({ ...prev, isEligibleToExport: false }))}
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
!draft.isEligibleToExport
? "bg-white shadow-sm text-primary"
: "text-on-surface-variant hover:text-on-surface"
}`}
>
No
</button>
<button
type="button"
onClick={() => setDraft((prev) => ({ ...prev, isEligibleToExport: true }))}
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
draft.isEligibleToExport
? "bg-white shadow-sm text-primary"
: "text-on-surface-variant hover:text-on-surface"
}`}
>
Yes
</button>
</div>
</div>
</section>
{/* 6. Product Supporting Documents */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-2">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">folder_open</span>
</div>
<div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">Product Supporting Documents</h3>
<p className="text-[10px] text-on-surface-variant mt-0.5">
Upload manuals, datasheets, or other supporting files. Multiple documents allowed.
</p>
</div>
</div>
<div className="space-y-2 mt-4">
{(draft.productFiles ?? []).map((doc) => (
<div key={doc.id} className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
<span className="material-symbols-outlined text-primary text-[20px]">description</span>
<span className="flex-1 text-sm font-semibold text-on-surface truncate">{doc.name}</span>
<button
type="button"
onClick={() => removeDoc(doc.id)}
className="text-outline hover:text-error transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
))}
</div>
<input
ref={docInputRef}
type="file"
accept=".pdf,.docx,.doc,.xlsx,.xls,.pptx,.zip"
multiple
className="hidden"
onChange={async (e) => {
const files = Array.from(e.target.files || []);
for (const file of files) await uploadDoc(file);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => docInputRef.current?.click()}
disabled={uploadingDoc}
className="w-full mt-3 border border-dashed border-outline-variant rounded-xl px-4 py-7 flex flex-col items-center justify-center gap-2 bg-surface-container-low/20 hover:bg-surface-container-low/60 transition-all disabled:opacity-60"
>
{uploadingDoc ? (
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined text-primary text-2xl">cloud_upload</span>
)}
<div className="text-center">
<p className="text-sm font-bold text-on-surface">
{uploadingDoc ? "Uploading..." : "Upload Supporting Documents"}
</p>
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, ZIP · Multiple files allowed</p>
</div>
</button>
{docError && <p className="text-[11px] text-error mt-1.5 font-semibold">{docError}</p>}
</section>
{/* Fixed Bottom Footer */}
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-8">
<button
type="button"
disabled={submitting}
onClick={async () => { try { await submit("DRAFT"); router.push("/products"); } catch {} }}
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
>
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
{submitting ? s.saveDraft : s.saveDraft}
</button>
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{s.autoSaved}</span>
</div>
</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.push("/products/new/pricing")}
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
>
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
{s.back}
</button>
<button
type="button"
onClick={() => router.push("/products/new/review")}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
{s.next}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
export default function ProductSubmittedPage() {
return (
<div className="max-w-4xl mx-auto py-10">
<div className="bg-surface-container-lowest rounded-[2rem] border border-outline-variant/10 shadow-sm p-10 md:p-14 text-center">
<div className="mx-auto w-28 h-28 rounded-[2rem] bg-primary text-white flex items-center justify-center shadow-2xl shadow-primary/25 mb-10">
<span
className="material-symbols-outlined text-6xl"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
</div>
<h2 className="text-4xl font-black font-headline tracking-tight text-on-surface mb-5">
Product listing flow submitted.
</h2>
<p className="max-w-2xl mx-auto text-on-surface-variant font-medium leading-relaxed">
This confirmation page is still UI-first. Once API integration is
attached, this route will be shown after `create product` and
`submit-review product` complete successfully.
</p>
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href="/products"
className="px-7 py-4 rounded-xl bg-primary text-white font-black uppercase tracking-[0.18em] text-sm shadow-lg shadow-primary/20"
>
Product Listing
</Link>
<Link
href="/products/new/category"
className="px-7 py-4 rounded-xl border border-outline-variant/30 text-on-surface font-black uppercase tracking-[0.18em] text-sm hover:bg-surface-container-low transition-colors"
>
Create Another
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,527 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context";
type TabLabel =
| "All Product"
| "Draft"
| "In Review"
| "International Market"
| "Local Market"
| "Out Of Stock"
| "Rejected";
interface ProductRow {
id: string;
image: string | null;
isFavorite: boolean;
market: string;
maxPrice: number;
minPrice: number;
name: string;
state?: string | null;
status?: string | null;
reviewStatus?: string | null;
totalStock: number;
}
function getToken() {
if (typeof window === "undefined") {
return "";
}
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function formatPrice(product: ProductRow) {
if (!product.minPrice && !product.maxPrice) {
return "-";
}
const formatter = new Intl.NumberFormat("id-ID");
if (product.minPrice === product.maxPrice) {
return `Rp ${formatter.format(product.minPrice)}`;
}
return `Rp ${formatter.format(product.minPrice)} - ${formatter.format(product.maxPrice)}`;
}
function marketClasses(market: string) {
return market === "International"
? "bg-secondary-fixed text-on-secondary-fixed"
: "bg-tertiary-fixed text-on-tertiary-fixed";
}
function tabFromQuery(tab: string | null): TabLabel {
switch (tab) {
case "draft":
return "Draft";
case "in-review":
return "In Review";
case "international-market":
return "International Market";
case "local-market":
return "Local Market";
case "out-of-stock":
return "Out Of Stock";
case "rejected":
return "Rejected";
default:
return "All Product";
}
}
function DeleteConfirmModal({
product,
onConfirm,
onCancel,
deleting,
d,
}: {
product: ProductRow;
onConfirm: () => void;
onCancel: () => void;
deleting: boolean;
d: { title: string; message: string; productLabel: string; cancel: string; confirm: string; deleting: string };
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
<div className="relative w-full max-w-md rounded-2xl bg-surface-container-lowest border border-outline-variant/10 shadow-2xl p-8 space-y-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-error-container flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-error text-2xl">delete_forever</span>
</div>
<div>
<h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2>
<p className="text-sm text-on-surface-variant mt-1">{d.message}</p>
</div>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.productLabel}</p>
<p className="text-sm font-bold text-on-surface">{product.name}</p>
<p className="text-xs text-outline mt-0.5">ID: {product.id}</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
disabled={deleting}
className="flex-1 px-4 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm hover:bg-surface-container-low transition-colors disabled:opacity-50"
>
{d.cancel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={deleting}
className="flex-1 px-4 py-3 rounded-xl bg-error text-white font-black text-sm hover:bg-error/90 transition-colors disabled:opacity-60 flex items-center justify-center gap-2"
>
{deleting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
{d.deleting}
</>
) : (
<>
<span className="material-symbols-outlined text-base">delete</span>
{d.confirm}
</>
)}
</button>
</div>
</div>
</div>
);
}
function ProductsPageInner() {
const { t } = useLanguage();
const p = t.dashboard.products;
const searchParams = useSearchParams();
const tab = searchParams.get("tab");
const activeTab = tabFromQuery(tab);
const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [totalItem, setTotalItem] = useState(0);
const [totalPage, setTotalPage] = useState(0);
const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<ProductRow | null>(null);
const [deleting, setDeleting] = useState(false);
// Reset to page 1 when tab changes
useEffect(() => {
setPage(1);
}, [tab]);
useEffect(() => {
async function loadProducts() {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (tab) params.set("tab", tab);
params.set("page", String(page));
params.set("size", "20");
const res = await fetch(`/api/products?${params.toString()}`,
{ headers: { "x-auth-token": getToken() } }
);
const result = await res.json();
if (!res.ok) {
throw new Error(result?.responseDesc || "Failed to load products");
}
setRows(result?.rows || result?.data?.rows || []);
setTotalItem(result?.totalItem || result?.data?.totalItem || 0);
setTotalPage(result?.totalPage || result?.data?.totalPage || 0);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load products");
} finally {
setLoading(false);
}
}
loadProducts();
}, [tab, page]);
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
const isDraft = tab === "draft";
const url = `/api/products/${deleteTarget.id}${isDraft ? "?draft=1" : ""}`;
await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
setDeleteTarget(null);
window.location.reload();
} catch {
alert(p.deleteDialog.errorGeneric);
} finally {
setDeleting(false);
}
}
const internationalCount = rows.filter(
(row) => row.market === "International"
).length;
const localCount = rows.filter((row) => row.market === "Local Market").length;
return (
<div className="px-8 pb-12 pt-24">
<section className="mb-6 flex flex-col gap-4 md:flex-row">
<div className="relative max-w-sm flex-1 overflow-hidden rounded-2xl bg-primary-container p-5 text-white">
<div className="relative z-10">
<p className="mb-1 text-[10px] font-black uppercase tracking-[0.18em] text-white/70">
{p.totalItems}
</p>
<h1 className="mb-2 font-headline text-4xl font-black tracking-tighter">
{totalItem || rows.length}
</h1>
<div className="inline-flex items-center gap-2 rounded-full bg-white/20 px-2.5 py-1 text-xs font-bold backdrop-blur">
<span className="material-symbols-outlined text-sm">
trending_up
</span>
<span>{p.activeCatalog}</span>
</div>
</div>
<span className="material-symbols-outlined absolute -bottom-6 -right-6 text-[96px] text-white/10">
inventory_2
</span>
</div>
<div className="flex-1 rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5">
<div className="mb-1 flex items-baseline gap-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.marketSplit}
</p>
<h2 className="font-headline text-2xl font-black tracking-tighter text-on-surface">
{internationalCount} / {localCount}
</h2>
</div>
<div className="mb-2 h-1.5 w-48 overflow-hidden rounded-full bg-surface-container">
<div
className="h-full bg-tertiary"
style={{
width: `${rows.length ? (internationalCount / rows.length) * 100 : 0}%`,
}}
/>
</div>
<p className="text-[11px] font-medium text-on-surface-variant">
{p.marketSplitDesc}
</p>
</div>
</section>
<section className="overflow-hidden rounded-[20px] border border-outline-variant/10 bg-surface-container-lowest shadow-sm">
<div className="border-b border-surface-container px-4 py-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.activeView}
</p>
<h2 className="mt-1 font-headline text-2xl font-black tracking-tighter text-on-surface">
{activeTab}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
href="/products/new/category"
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-xs font-black text-white transition-transform active:scale-95"
>
<span className="material-symbols-outlined text-sm">add</span>
{p.addProduct}
</Link>
<button className="flex h-10 w-10 items-center justify-center rounded-xl border-2 border-surface-container text-on-surface-variant transition-colors hover:border-primary hover:text-primary">
<span className="material-symbols-outlined text-xl">
filter_list
</span>
</button>
</div>
</div>
</div>
{error ? (
<div className="px-6 py-6 text-sm font-bold text-error">{error}</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<thead className="bg-surface-container-low/60">
<tr>
<th className="w-4 px-6 py-4">
<input
type="checkbox"
className="h-4 w-4 rounded border-surface-container text-primary focus:ring-primary"
/>
</th>
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.table.product}
</th>
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.table.price}
</th>
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.table.stock}
</th>
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.table.market}
</th>
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
{p.table.action}
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-container">
{loading ? (
<tr>
<td
colSpan={6}
className="px-6 py-10 text-center text-sm font-bold text-on-surface-variant"
>
{p.loading}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-10 text-center text-sm font-bold text-on-surface-variant"
>
{p.empty}
</td>
</tr>
) : (
rows.map((product) => {
const stockTone =
product.totalStock === 0
? "red"
: product.totalStock <= 20
? "amber"
: "green";
return (
<tr
key={product.id}
className={`group transition-colors hover:bg-surface-container-low/60 ${
stockTone === "red" ? "bg-error-container/10" : ""
}`}
>
<td className="w-4 px-6 py-4">
<input
type="checkbox"
className="h-4 w-4 rounded border-surface-container text-primary focus:ring-primary"
/>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded-lg bg-surface-container">
{product.image ? (
<Image
alt={product.name}
src={product.image}
fill
sizes="44px"
className={`object-cover ${
stockTone === "red" ? "grayscale" : ""
}`}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] font-black text-outline">
N/A
</div>
)}
</div>
<div>
<p
className={`text-xs font-bold transition-colors group-hover:text-primary ${
stockTone === "red"
? "text-outline line-through"
: "text-on-surface"
}`}
>
{product.name}
</p>
<p className="text-[10px] font-medium text-outline">
ID: {product.id.slice(0, 8)}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-center">
<span
className={`text-xs font-bold ${
stockTone === "red"
? "text-outline"
: "text-on-surface"
}`}
>
{formatPrice(product)}
</span>
</td>
<td className="px-6 py-4 text-center">
<div
className={`flex items-center justify-center gap-1.5 ${
stockTone === "red" ? "text-error" : ""
}`}
>
<div
className={`h-1.5 w-1.5 rounded-full ${
stockTone === "red"
? "bg-error"
: stockTone === "amber"
? "bg-amber-500"
: "bg-green-500"
}`}
/>
<span className="text-xs font-bold">
{product.totalStock === 0
? "Out of Stock"
: product.totalStock}
</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-tight ${marketClasses(
product.market
)}`}
>
{product.market}
</span>
</td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-3">
<Link
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
>
{p.edit}
</Link>
<Link
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : ""}`}
className="text-[10px] font-bold text-primary transition-colors hover:underline"
>
{p.detail}
</Link>
<button
type="button"
onClick={() => setDeleteTarget(product)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors"
title="Hapus"
>
<span className="material-symbols-outlined text-base">delete</span>
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between bg-surface-container-low/50 px-6 py-3">
<p className="text-[10px] font-medium text-on-surface-variant">
{p.table.showing} {rows.length} {p.table.of} {totalItem || rows.length} {p.table.products}
</p>
<div className="flex items-center gap-1.5">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
className="rounded-lg border border-surface-container p-1.5 text-on-surface-variant transition-colors hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-xs">chevron_left</span>
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-[10px] font-bold text-white">
{page}
</button>
<span className="px-2 text-[10px] font-bold text-on-surface-variant">
/ {Math.max(totalPage, 1)}
</span>
<button
onClick={() => setPage((p) => Math.min(Math.max(totalPage, 1), p + 1))}
disabled={page >= totalPage || loading}
className="rounded-lg border border-surface-container p-1.5 text-on-surface-variant transition-colors hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-xs">chevron_right</span>
</button>
</div>
</div>
</section>
{deleteTarget && (
<DeleteConfirmModal
product={deleteTarget}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
deleting={deleting}
d={p.deleteDialog}
/>
)}
</div>
);
}
export default function ProductsPage() {
return (
<Suspense fallback={
<div className="px-8 pb-12 pt-24 text-sm font-semibold text-on-surface-variant">
Loading products...
</div>
}>
<ProductsPageInner />
</Suspense>
);
}

View File

@ -0,0 +1,295 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context";
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function getPasswordStrength(password: string): number {
if (!password) return 0;
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
return Math.min(score, 4);
}
function getStrengthColor(score: number): string {
if (score === 1) return "#ba1a1a";
if (score === 2) return "#b7131a";
if (score === 3) return "#2d9648";
return "#1b7a3c";
}
// ─── Password Field ───────────────────────────────────────────────────────────
function PasswordField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
const [show, setShow] = useState(false);
return (
<div className="space-y-1.5">
<label className="block text-[10px] font-black uppercase tracking-[0.18em] text-on-surface-variant">
{label}
</label>
<div className="relative">
<input
type={show ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="••••••••••••"
className="w-full bg-transparent border-b-2 border-outline-variant/40 focus:border-primary pb-2 pt-1 pr-10 text-sm font-semibold text-on-surface placeholder:text-on-surface-variant/30 focus:outline-none transition-colors"
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShow((s) => !s)}
className="absolute right-0 top-0 text-on-surface-variant/60 hover:text-on-surface transition-colors"
>
<span className="material-symbols-outlined text-[20px]">
{show ? "visibility_off" : "visibility"}
</span>
</button>
</div>
</div>
);
}
// ─── Strength Bar ─────────────────────────────────────────────────────────────
function StrengthBar({
password,
prefix,
labels,
}: {
password: string;
prefix: string;
labels: [string, string, string, string];
}) {
const score = getPasswordStrength(password);
const color = getStrengthColor(score);
const label = score > 0 ? labels[score - 1] : "";
if (!password) return null;
return (
<div className="space-y-1.5 mt-1">
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-1 flex-1 rounded-full transition-all duration-300"
style={{ backgroundColor: i <= score ? color : "#e4beb9" }}
/>
))}
</div>
<p className="text-[10px] font-black tracking-[0.15em]" style={{ color }}>
{prefix} {label}
</p>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ChangePasswordPage() {
const router = useRouter();
const { t } = useLanguage();
const cp = t.dashboard.changePassword;
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const strengthLabels: [string, string, string, string] = [
cp.strengthWeak,
cp.strengthModerate,
cp.strengthStrong,
cp.strengthVeryStrong,
];
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setSuccess(false);
if (!currentPassword || !newPassword || !confirmPassword) {
setError(cp.errorRequired);
return;
}
if (newPassword !== confirmPassword) {
setError(cp.errorMismatch);
return;
}
if (getPasswordStrength(newPassword) < 2) {
setError(cp.errorWeak);
return;
}
setSaving(true);
try {
const res = await fetch("/api/profile/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-auth-token": getToken(),
},
body: JSON.stringify({ oldPassword: currentPassword, newPassword }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.responseDesc || data?.message || cp.errorGeneric);
}
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (err) {
setError(err instanceof Error ? err.message : cp.errorGeneric);
} finally {
setSaving(false);
}
}
return (
<div className="w-full px-6 md:px-10 py-8 pb-10">
{/* Page Header */}
<div className="mb-8">
<h1 className="font-headline font-black text-4xl text-on-surface tracking-tight mb-1">
{cp.title}
</h1>
<nav className="flex items-center gap-2 text-[10px] uppercase tracking-widest font-bold text-outline">
<span>{cp.settings}</span>
<span>/</span>
<span className="text-primary">{cp.security}</span>
</nav>
</div>
{/* Card */}
<div className="flex rounded-2xl overflow-hidden shadow-lg border border-outline-variant/10 max-w-4xl">
{/* ── Left: Form ──────────────────────────────────────────────── */}
<div className="flex-1 bg-surface-container-lowest p-8 md:p-10 space-y-6">
{success && (
<div className="rounded-xl border border-tertiary/20 bg-tertiary/5 px-4 py-3 text-sm font-semibold text-tertiary flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
{cp.successMessage}
</div>
)}
{error && (
<div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container flex items-center gap-2">
<span className="material-symbols-outlined text-error text-[18px]">error</span>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-7">
<PasswordField label={cp.currentPassword} value={currentPassword} onChange={setCurrentPassword} />
<div className="space-y-2">
<PasswordField label={cp.newPassword} value={newPassword} onChange={setNewPassword} />
<StrengthBar password={newPassword} prefix={cp.strengthPrefix} labels={strengthLabels} />
</div>
<PasswordField label={cp.confirmNewPassword} value={confirmPassword} onChange={setConfirmPassword} />
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-[11px] font-semibold text-error -mt-4">{cp.mismatch}</p>
)}
<div className="flex items-center gap-5 pt-2">
<button
type="submit"
disabled={saving}
className="editorial-gradient text-white px-8 py-3 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-md shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0 flex items-center gap-2"
>
{saving ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
{cp.saving}
</>
) : (
<>
<span className="material-symbols-outlined text-[18px]">lock_reset</span>
{cp.updatePassword}
</>
)}
</button>
<button
type="button"
onClick={() => router.back()}
disabled={saving}
className="text-sm font-black uppercase tracking-[0.15em] text-on-surface-variant hover:text-primary transition-colors disabled:opacity-40"
>
{cp.cancel}
</button>
</div>
</form>
</div>
{/* ── Right: Security Guidelines ──────────────────────────── */}
<div
className="w-72 shrink-0 flex flex-col justify-between p-8"
style={{ background: "linear-gradient(160deg, #1a1f2e 0%, #2e3132 100%)" }}
>
<div className="space-y-6">
<h2 className="font-headline font-black text-base uppercase tracking-[0.2em] text-white whitespace-pre-line">
{cp.guidelines.title}
</h2>
<div className="space-y-5">
{[
{ title: cp.guidelines.length, desc: cp.guidelines.lengthDesc },
{ title: cp.guidelines.mix, desc: cp.guidelines.mixDesc },
{ title: cp.guidelines.entropy, desc: cp.guidelines.entropyDesc },
].map((item) => (
<div key={item.title} className="flex gap-3">
<div className="mt-0.5 w-5 h-5 rounded-full bg-primary/80 flex items-center justify-center shrink-0">
<span
className="material-symbols-outlined text-white text-[13px]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
location_on
</span>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-white mb-0.5">
{item.title}
</p>
<p className="text-[11px] text-white/60 font-medium leading-relaxed">
{item.desc}
</p>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 pt-6 border-t border-white/10 mt-6">
<span className="material-symbols-outlined text-white/40 text-[16px]">lock</span>
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-white/40">
{cp.guidelines.lastChanged}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { t } = useLanguage();
const s = t.dashboard.settings;
const settingsNav = [
{ href: "/settings", icon: "account_circle", label: s.profile },
{ href: "/settings/change-password", icon: "shield", label: s.changePassword },
];
return (
<div className="flex min-h-screen">
{/* Settings sub-sidebar */}
<aside className="w-56 shrink-0 bg-surface-container-lowest border-r border-surface-container py-8 px-3 pt-12">
<p className="text-[9px] font-black uppercase tracking-[0.2em] text-outline px-3 mb-3">
{s.account}
</p>
<nav className="space-y-0.5">
{settingsNav.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-all ${
isActive
? "bg-primary/8 text-primary border-l-4 border-primary rounded-l-none"
: "text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform"
}`}
>
<span
className="material-symbols-outlined text-[20px]"
style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}
>
{item.icon}
</span>
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
{/* Page content */}
<div className="flex-1 min-w-0">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,571 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useLanguage } from "@/lib/i18n-context";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
interface SellerProfile {
biography: string;
sellerId: string;
sellerImageUrl: string | null;
storeImageUrl: string | null;
storeName: string;
}
// ─── Avatar Upload ─────────────────────────────────────────────────────────────
function AvatarUpload({
currentUrl,
previewUrl,
onUploaded,
editMode,
}: {
currentUrl: string | null;
previewUrl: string;
onUploaded: (fileId: string, objectUrl: string) => void;
editMode: boolean;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const objectUrl = URL.createObjectURL(file);
setUploading(true);
setError("");
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: fd,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id, objectUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal");
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
const displayUrl = previewUrl || currentUrl;
return (
<div className="relative group">
<div className="w-48 h-48 rounded-full border-4 border-primary/10 mb-6 overflow-hidden bg-surface-container-low flex items-center justify-center">
{displayUrl ? (
<img
src={displayUrl}
alt="Seller Profile"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<span
className="material-symbols-outlined text-6xl text-outline/30"
style={{ fontVariationSettings: "'FILL' 1" }}
>
person
</span>
)}
</div>
{editMode && (
<>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="absolute bottom-6 right-4 bg-primary text-white p-3 rounded-full shadow-xl hover:scale-110 transition-transform disabled:opacity-60"
>
{uploading ? (
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined text-lg">photo_camera</span>
)}
</button>
{error && (
<p className="text-[10px] text-error text-center mt-1">{error}</p>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleChange}
className="hidden"
/>
</>
)}
</div>
);
}
// ─── Store Photo Upload ────────────────────────────────────────────────────────
function StorePhotoUpload({
currentUrl,
previewUrl,
onUploaded,
onRemove,
editMode,
}: {
currentUrl: string | null;
previewUrl: string;
onUploaded: (fileId: string, objectUrl: string) => void;
onRemove: () => void;
editMode: boolean;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const objectUrl = URL.createObjectURL(file);
setUploading(true);
setError("");
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: fd,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id, objectUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal");
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
const displayUrl = previewUrl || currentUrl;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Preview */}
<div className="relative aspect-video bg-surface-container-lowest overflow-hidden group rounded-xl">
{displayUrl ? (
<>
<img
src={displayUrl}
alt="Store Photo"
className="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-500"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
{editMode && (
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4">
<button
type="button"
onClick={onRemove}
className="bg-primary/80 backdrop-blur-md p-3 rounded-xl text-white hover:bg-primary transition-colors"
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
)}
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-outline/30">
<span className="material-symbols-outlined text-5xl mb-2">image</span>
<p className="text-xs font-semibold">No store photo</p>
</div>
)}
</div>
{/* Upload Area */}
{editMode ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="border-2 border-dashed border-outline-variant bg-surface-container-lowest/50 flex flex-col items-center justify-center p-8 text-center hover:bg-white transition-all rounded-xl disabled:opacity-60"
>
{uploading ? (
<>
<div className="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-xs font-bold text-outline uppercase tracking-wider">Uploading...</p>
</>
) : (
<>
<span className="material-symbols-outlined text-4xl text-outline/30 mb-4">cloud_upload</span>
<p className="text-xs font-bold text-on-surface uppercase tracking-wider mb-2">Upload New Asset</p>
<p className="text-[10px] text-outline px-4">JPG, PNG or WEBP. Max 5MB. Recommended 1600×900px.</p>
</>
)}
{error && <p className="text-[10px] text-error mt-2">{error}</p>}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleChange}
className="hidden"
/>
</button>
) : (
<div className="border-2 border-dashed border-outline-variant/30 rounded-xl flex flex-col items-center justify-center p-8 text-center bg-surface-container-lowest/30">
<span className="material-symbols-outlined text-3xl text-outline/20 mb-2">cloud_upload</span>
<p className="text-[10px] text-outline/40 uppercase tracking-wider font-bold">Enable edit to upload</p>
</div>
)}
</div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const { t } = useLanguage();
const s = t.dashboard.settings;
const [profile, setProfile] = useState<SellerProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState(false);
// Form state
const [storeName, setStoreName] = useState("");
const [biography, setBiography] = useState("");
const [sellerImageId, setSellerImageId] = useState("");
const [sellerImagePreview, setSellerImagePreview] = useState("");
const [storeImageId, setStoreImageId] = useState("");
const [storeImagePreview, setStoreImagePreview] = useState("");
useEffect(() => {
fetch("/api/seller/profile", {
headers: { "x-auth-token": getToken() },
})
.then((r) => r.json())
.then((j) => {
const d: SellerProfile = j?.data || j;
setProfile(d);
setStoreName(d.storeName || "");
setBiography(d.biography || "");
})
.catch(() => setError("Gagal memuat profil"))
.finally(() => setLoading(false));
}, []);
function handleEdit() {
setEditMode(true);
setSaveError("");
setSaveSuccess(false);
}
function handleCancel() {
if (!profile) return;
setStoreName(profile.storeName || "");
setBiography(profile.biography || "");
setSellerImageId("");
setSellerImagePreview("");
setStoreImageId("");
setStoreImagePreview("");
setEditMode(false);
setSaveError("");
}
async function handleSave() {
setSaving(true);
setSaveError("");
setSaveSuccess(false);
try {
const body: Record<string, unknown> = {
storeName,
storeBiography: biography,
};
if (sellerImageId) body.imageId = sellerImageId;
if (storeImageId) body.storeImageId = storeImageId;
const res = await fetch("/api/seller/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-auth-token": getToken(),
},
body: JSON.stringify(body),
});
const result = await res.json();
if (!res.ok) throw new Error(result?.responseDesc || "Gagal menyimpan profil");
// Update local profile state
setProfile((prev) => prev ? {
...prev,
storeName,
biography,
sellerImageUrl: sellerImagePreview || prev.sellerImageUrl,
storeImageUrl: storeImagePreview || prev.storeImageUrl,
} : prev);
setSaveSuccess(true);
setEditMode(false);
setSellerImageId("");
setSellerImagePreview("");
setStoreImageId("");
setStoreImagePreview("");
} catch (err) {
setSaveError(err instanceof Error ? err.message : s.errorSave);
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="w-full px-6 md:px-10 py-8 flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm font-semibold text-on-surface-variant">{s.loading}</p>
</div>
</div>
);
}
if (error || !profile) {
return (
<div className="w-full px-6 md:px-10 py-8">
<div className="rounded-xl border border-error/20 bg-error-container p-6 text-sm font-semibold text-on-error-container">
{error || s.profileNotFound}
</div>
</div>
);
}
return (
<div className="w-full px-6 md:px-10 py-8 pb-10 space-y-10">
{/* Page Header */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="font-headline font-black text-4xl md:text-5xl text-on-surface tracking-tight mb-2">
{s.sellerProfile}
</h1>
<nav className="flex items-center gap-2 text-[10px] uppercase tracking-widest font-bold text-outline">
<span>{s.management}</span>
<span>/</span>
<span className="text-primary">{s.profileConfiguration}</span>
</nav>
</div>
<div className="flex items-center gap-3">
{editMode ? (
<>
<button
type="button"
onClick={handleCancel}
disabled={saving}
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low transition-all disabled:opacity-40"
>
{t.common.cancel}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-8 py-3 editorial-gradient text-white font-bold rounded-xl shadow-lg shadow-primary/20 hover:-translate-y-0.5 active:translate-y-0 transition-all disabled:opacity-60 flex items-center gap-2"
>
{saving ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
{s.saving}
</>
) : (
<>
<span className="material-symbols-outlined text-[18px]">save</span>
{s.saveChanges}
</>
)}
</button>
</>
) : (
<button
type="button"
onClick={handleEdit}
className="px-8 py-3 editorial-gradient text-white font-bold rounded-xl shadow-lg shadow-primary/20 hover:-translate-y-0.5 active:translate-y-0 transition-all flex items-center gap-2"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
{s.editProfile}
</button>
)}
</div>
</div>
{/* Alerts */}
{saveSuccess && (
<div className="rounded-xl border border-tertiary/20 bg-tertiary/5 px-5 py-4 text-sm font-semibold text-tertiary flex items-center gap-3">
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
{s.successUpdate}
</div>
)}
{saveError && (
<div className="rounded-xl border border-error/20 bg-error-container px-5 py-4 text-sm font-semibold text-on-error-container flex items-center gap-3">
<span className="material-symbols-outlined text-error text-[20px]">error</span>
{saveError}
</div>
)}
{/* Bento Grid */}
<div className="grid grid-cols-12 gap-6">
{/* ── Left: Profile Identity ─────────────────────────────────────── */}
<section className="col-span-12 lg:col-span-4 bg-surface-container-low p-8 rounded-2xl border border-outline-variant/10 shadow-sm flex flex-col items-center text-center">
<AvatarUpload
currentUrl={profile.sellerImageUrl}
previewUrl={sellerImagePreview}
onUploaded={(id, url) => {
setSellerImageId(id);
setSellerImagePreview(url);
}}
editMode={editMode}
/>
<h3 className="font-headline font-extrabold text-2xl text-on-surface mb-1">
{storeName || profile.storeName || "—"}
</h3>
<p className="text-sm text-on-surface-variant font-medium mt-2 leading-relaxed max-w-xs">
{biography || profile.biography || ""}
</p>
{/* Seller ID badge */}
<div className="mt-6 w-full p-4 rounded-xl bg-surface-container border border-outline-variant/10">
<p className="text-[9px] font-black uppercase tracking-[0.2em] text-outline mb-1">{s.sellerId}</p>
<p className="text-xs font-mono font-bold text-on-surface break-all">{profile.sellerId}</p>
</div>
</section>
{/* ── Right: Forms ───────────────────────────────────────────────── */}
<section className="col-span-12 lg:col-span-8 space-y-6">
{/* Store Information */}
<div className="bg-surface-container-low p-8 md:p-10 rounded-2xl border border-outline-variant/10 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 w-40 h-40 bg-primary/5 rounded-bl-full pointer-events-none" />
<div className="relative z-10 space-y-8">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-primary text-white flex items-center justify-center rounded-xl shadow-md shadow-primary/20">
<span className="material-symbols-outlined">edit_note</span>
</div>
<h2 className="font-headline font-extrabold text-2xl tracking-tight text-on-surface">
{s.storeInfo}
</h2>
</div>
<div className="space-y-6">
<div>
<label className="block text-[10px] font-black text-outline uppercase tracking-[0.2em] mb-3">
{s.storeName}
</label>
{editMode ? (
<input
value={storeName}
onChange={(e) => setStoreName(e.target.value)}
placeholder="Enter formal store name"
className="w-full bg-surface-container-lowest border-none rounded-xl px-6 py-4 text-on-surface font-bold text-lg shadow-sm focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all"
/>
) : (
<p className="w-full bg-surface-container-lowest rounded-xl px-6 py-4 text-on-surface font-bold text-lg shadow-sm">
{profile.storeName || <span className="text-outline font-medium"></span>}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black text-outline uppercase tracking-[0.2em] mb-3">
{s.storeBiography}
</label>
{editMode ? (
<textarea
value={biography}
onChange={(e) => setBiography(e.target.value)}
rows={4}
placeholder="Describe your store's mission and value proposition"
className="w-full bg-surface-container-lowest border-none rounded-xl px-6 py-4 text-on-surface font-medium text-sm leading-relaxed shadow-sm focus:ring-2 focus:ring-primary/20 focus:outline-none resize-none transition-all"
/>
) : (
<p className="w-full bg-surface-container-lowest rounded-xl px-6 py-4 text-on-surface font-medium text-sm leading-relaxed shadow-sm min-h-[7rem] whitespace-pre-line">
{profile.biography || <span className="text-outline"></span>}
</p>
)}
</div>
</div>
</div>
</div>
{/* Store Photo */}
<div className="bg-surface-container-low p-8 md:p-10 rounded-2xl border border-outline-variant/10 shadow-sm">
<div className="flex items-center gap-4 mb-8">
<div className="w-10 h-10 bg-primary text-white flex items-center justify-center rounded-xl shadow-md shadow-primary/20">
<span className="material-symbols-outlined">add_a_photo</span>
</div>
<h2 className="font-headline font-extrabold text-2xl tracking-tight text-on-surface">
{s.storePhoto}
</h2>
</div>
<StorePhotoUpload
currentUrl={profile.storeImageUrl}
previewUrl={storeImagePreview}
onUploaded={(id, url) => {
setStoreImageId(id);
setStoreImagePreview(url);
}}
onRemove={() => {
setStoreImageId("");
setStoreImagePreview("");
setProfile((prev) => prev ? { ...prev, storeImageUrl: null } : prev);
}}
editMode={editMode}
/>
</div>
{/* Action Footer */}
<div className="flex items-center justify-between px-8 py-6 bg-surface-container-highest/20 border-l-4 border-primary rounded-r-2xl">
<div className="flex items-center gap-4">
<span
className="material-symbols-outlined text-primary text-[22px] shrink-0"
style={{ fontVariationSettings: "'FILL' 1" }}
>
info
</span>
<p className="text-[11px] font-medium text-on-surface-variant max-w-sm leading-relaxed">
{s.complianceNote}
</p>
</div>
<button
type="button"
className="flex items-center gap-2 text-primary font-black uppercase text-[10px] tracking-[0.2em] hover:translate-x-1 transition-transform flex-shrink-0 ml-4"
>
{s.viewStorefront}
<span className="material-symbols-outlined text-sm">arrow_forward</span>
</button>
</div>
</section>
</div>
</div>
);
}