feat: add Ina Trading portal flows and API integration
This commit is contained in:
295
src/app/(dashboard)/settings/change-password/page.tsx
Normal file
295
src/app/(dashboard)/settings/change-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/app/(dashboard)/settings/layout.tsx
Normal file
56
src/app/(dashboard)/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
571
src/app/(dashboard)/settings/page.tsx
Normal file
571
src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user