feat: implement user profile, order history, and address book with checkout integration

This commit is contained in:
Your Name committed 2026-06-07 18:48:56 +07:00
1 parent bae945b9d4
commit 0c3c450e1f
9 files changed
+1087 -10

No files matched your search

@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "CustomerAddress" (
"id" SERIAL NOT NULL,
"customerId" INTEGER NOT NULL,
"recipientName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"addressLine" TEXT NOT NULL,
"subdistrict" TEXT NOT NULL,
"district" TEXT NOT NULL,
"province" TEXT NOT NULL,
"zipcode" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -85,6 +85,23 @@ model Customer {
points Int @default(0)
orders Order[]
reviews Review[]
addresses CustomerAddress[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CustomerAddress {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
recipientName String
phone String
addressLine String // บ้านเลขที่ หมู่บ้าน ถนน ซอย
subdistrict String // แขวง/ตำบล
district String // เขต/อำเภอ
province String // จังหวัด
zipcode String // รหัสไปรษณีย์
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+2 -1
View File
@@ -22,6 +22,7 @@ import { reviewsRoute } from "./routes/reviews";
import { promotionsRoute } from "./routes/promotions";
import { pagesRoute } from "./routes/pages";
import { paymentsRoute } from "./routes/payments";
import { profileRoute } from "./routes/profile";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
@@ -85,7 +86,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute)
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute)
)
.listen(PORT);
+162
View File
@@ -0,0 +1,162 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const profileRoute = new Elysia({ prefix: "/profile" })
.get("/:customerId", async ({ params: { customerId }, set }) => {
const customer = await prisma.customer.findUnique({
where: { id: Number(customerId) },
include: { addresses: true }
});
if (!customer) {
set.status = 404;
return { error: "Customer not found" };
}
return customer;
})
.put("/:customerId", async ({ params: { customerId }, body, set }) => {
try {
const { email, phone } = body as any;
const customer = await prisma.customer.update({
where: { id: Number(customerId) },
data: {
email: email || null,
phone: phone || null
}
});
return customer;
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to update customer profile" };
}
}, {
body: t.Object({
email: t.Optional(t.Nullable(t.String())),
phone: t.Optional(t.Nullable(t.String()))
})
})
.get("/:customerId/addresses", async ({ params: { customerId } }) => {
return await prisma.customerAddress.findMany({
where: { customerId: Number(customerId) },
orderBy: { isDefault: 'desc' }
});
})
.post("/:customerId/addresses", async ({ params: { customerId }, body, set }) => {
try {
const { recipientName, phone, addressLine, subdistrict, district, province, zipcode, isDefault } = body as any;
const cId = Number(customerId);
// If isDefault is true, unset other defaults first
if (isDefault) {
await prisma.customerAddress.updateMany({
where: { customerId: cId },
data: { isDefault: false }
});
}
const address = await prisma.customerAddress.create({
data: {
customerId: cId,
recipientName,
phone,
addressLine,
subdistrict,
district,
province,
zipcode,
isDefault: !!isDefault
}
});
return address;
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to create address" };
}
}, {
body: t.Object({
recipientName: t.String(),
phone: t.String(),
addressLine: t.String(),
subdistrict: t.String(),
district: t.String(),
province: t.String(),
zipcode: t.String(),
isDefault: t.Boolean()
})
})
.put("/:customerId/addresses/:addressId", async ({ params: { customerId, addressId }, body, set }) => {
try {
const { recipientName, phone, addressLine, subdistrict, district, province, zipcode, isDefault } = body as any;
const cId = Number(customerId);
const aId = Number(addressId);
// If setting as default, unset other defaults first
if (isDefault) {
await prisma.customerAddress.updateMany({
where: { customerId: cId },
data: { isDefault: false }
});
}
const address = await prisma.customerAddress.update({
where: { id: aId },
data: {
recipientName,
phone,
addressLine,
subdistrict,
district,
province,
zipcode,
isDefault: !!isDefault
}
});
return address;
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to update address" };
}
}, {
body: t.Object({
recipientName: t.String(),
phone: t.String(),
addressLine: t.String(),
subdistrict: t.String(),
district: t.String(),
province: t.String(),
zipcode: t.String(),
isDefault: t.Boolean()
})
})
.delete("/:customerId/addresses/:addressId", async ({ params: { addressId }, set }) => {
try {
await prisma.customerAddress.delete({
where: { id: Number(addressId) }
});
return { success: true };
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to delete address" };
}
})
.patch("/:customerId/addresses/:addressId/default", async ({ params: { customerId, addressId }, set }) => {
try {
const cId = Number(customerId);
const aId = Number(addressId);
// Unset other defaults
await prisma.customerAddress.updateMany({
where: { customerId: cId },
data: { isDefault: false }
});
// Set this one as default
const address = await prisma.customerAddress.update({
where: { id: aId },
data: { isDefault: true }
});
return address;
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to set default address" };
}
});
+2
View File
@@ -19,6 +19,7 @@ import OrderSuccessPage from './pages/OrderSuccessPage'
import TrackOrderPage from './pages/TrackOrderPage'
import WishlistPage from './pages/WishlistPage'
import CustomPage from './pages/CustomPage'
import ProfilePage from './pages/ProfilePage'
import CartDrawer from './components/CartDrawer'
function App() {
@@ -52,6 +53,7 @@ function App() {
<Route path="/contact" element={<ContactPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/pages/:slug" element={<CustomPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
+14 -4
View File
@@ -183,20 +183,28 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{customer ? (
<div className="relative group flex items-center">
<Link to="/profile">
<img
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="Profile"
className="w-9 h-9 rounded-full border-2 border-spa-forest/30 cursor-pointer object-cover"
className="w-9 h-9 rounded-full border-2 border-spa-forest/30 cursor-pointer object-cover animate-fade-in"
/>
<div className="absolute top-full right-0 mt-4 w-32 bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-xl">
</Link>
<div className="absolute top-full right-0 mt-4 w-36 bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-xl">
<div className="p-1">
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">Signed in as</p>
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
</div>
<Link
to="/profile"
className="block px-3 py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest hover:bg-spa-light rounded-lg transition-colors font-bold"
>
My Profile
</Link>
<button
onClick={logoutLiff}
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
>
Logout
</button>
@@ -217,12 +225,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<div className="flex md:hidden items-center gap-1.5">
{customer ? (
<div className="relative">
<Link to="/profile">
<img
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="Profile"
onClick={() => setMobileOpen(true)}
className="w-7 h-7 rounded-full border-2 border-spa-forest/30 object-cover cursor-pointer"
/>
</Link>
</div>
) : (
<button
@@ -334,6 +343,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{[
{ to: '/', label: t('nav.home'), end: true },
{ to: '/shop', label: t('nav.shop') },
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
{ to: '/about', label: t('nav.about') },
+53 -5
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, formatPrice, type Category } from '../types'
import { API_BASE, formatPrice, type Category, type CustomerAddress } from '../types'
import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore'
import { Loader2 } from 'lucide-react'
@@ -36,6 +36,7 @@ export default function CheckoutPage() {
phone: '',
address: ''
})
const [savedAddresses, setSavedAddresses] = useState<CustomerAddress[]>([])
// Prefill form with LINE customer data and last used address
useEffect(() => {
@@ -47,12 +48,32 @@ export default function CheckoutPage() {
phone: prev.phone || customer.phone || ''
}))
// Fetch latest order to pre-fill address acting as an automatic Address Book
fetch(`${API_BASE}/orders/customer/${customer.id}`)
// Fetch saved addresses first
fetch(`${API_BASE}/profile/${customer.id}/addresses`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data) && data.length > 0) {
const lastOrder = data[0];
if (Array.isArray(data)) {
setSavedAddresses(data)
// If default address exists, pre-fill with it immediately
const defAddr = data.find(a => a.isDefault)
if (defAddr) {
const formatted = `${defAddr.addressLine} ต.${defAddr.subdistrict} อ.${defAddr.district} จ.${defAddr.province} ${defAddr.zipcode}`
setFormData({
name: defAddr.recipientName,
phone: defAddr.phone,
email: customer.email || '',
address: formatted
})
return // Skip order fallback
}
}
// Fallback: Fetch latest order to pre-fill address
fetch(`${API_BASE}/orders/customer/${customer.id}`)
.then(res => res.json())
.then(ordersData => {
if (Array.isArray(ordersData) && ordersData.length > 0) {
const lastOrder = ordersData[0];
setFormData(prev => ({
...prev,
name: prev.name || lastOrder.customerName || '',
@@ -63,6 +84,8 @@ export default function CheckoutPage() {
}
})
.catch(console.error)
})
.catch(console.error)
fetch(`${API_BASE}/customers/${customer.id}`)
.then(res => res.json())
@@ -351,6 +374,31 @@ export default function CheckoutPage() {
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all resize-none placeholder:text-spa-stone/50"
placeholder="123 Serenity Street..."
/>
{savedAddresses.length > 0 && (
<div className="mt-2 space-y-2">
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">เลือกดึงที่อยู่จัดส่งจากโปรไฟล์:</p>
<div className="flex flex-wrap gap-2">
{savedAddresses.map(addr => {
const formatted = `${addr.addressLine} ต.${addr.subdistrict} อ.${addr.district} จ.${addr.province} ${addr.zipcode}`
return (
<button
key={addr.id}
type="button"
onClick={() => setFormData({
name: addr.recipientName,
phone: addr.phone,
email: formData.email,
address: formatted
})}
className="px-3 py-1.5 bg-spa-sage/10 hover:bg-spa-sage/20 border border-spa-stone/15 text-[10px] font-bold rounded-lg text-spa-forest transition-all text-left max-w-full truncate"
>
{addr.recipientName} ({addr.province}) {addr.isDefault && '⭐️'}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
</div>
+800
View File
@@ -0,0 +1,800 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, formatPrice, type Category, type Order, type CustomerAddress } from '../types'
import { useAuthStore } from '../store/authStore'
import {
User,
MapPin,
Package,
Settings,
Plus,
Edit3,
Trash2,
Check,
Loader2,
ExternalLink,
Phone,
Mail,
Award
} from 'lucide-react'
export default function ProfilePage() {
const { token, customer, setAuth } = useAuthStore()
const [categories, setCategories] = useState<Category[]>([])
// Tab State
const [activeTab, setActiveTab] = useState<'orders' | 'addresses' | 'settings'>('orders')
// Data States
const [dbCustomer, setDbCustomer] = useState<any>(null)
const [orders, setOrders] = useState<Order[]>([])
const [addresses, setAddresses] = useState<CustomerAddress[]>([])
// Loading States
const [loadingOrders, setLoadingOrders] = useState(false)
const [loadingAddresses, setLoadingAddresses] = useState(false)
// Address Form States
const [showAddressForm, setShowAddressForm] = useState(false)
const [editingAddress, setEditingAddress] = useState<CustomerAddress | null>(null)
const [recipientName, setRecipientName] = useState('')
const [phone, setPhone] = useState('')
const [addressLine, setAddressLine] = useState('')
const [subdistrict, setSubdistrict] = useState('')
const [district, setDistrict] = useState('')
const [province, setProvince] = useState('')
const [zipcode, setZipcode] = useState('')
const [isDefault, setIsDefault] = useState(false)
const [submittingAddress, setSubmittingAddress] = useState(false)
// Settings Form States
const [emailInput, setEmailInput] = useState('')
const [phoneInput, setPhoneInput] = useState('')
const [submittingSettings, setSubmittingSettings] = useState(false)
const [settingsSuccess, setSettingsSuccess] = useState(false)
// Fetch Categories
useEffect(() => {
fetch(`${API_BASE}/categories`)
.then(res => res.json())
.then(setCategories)
.catch(console.error)
}, [])
// Fetch Customer Profile details & Points dynamically
const fetchProfile = () => {
if (!customer) return
fetch(`${API_BASE}/profile/${customer.id}`)
.then(res => res.json())
.then(data => {
if (data && !data.error) {
setDbCustomer(data)
setEmailInput(data.email || '')
setPhoneInput(data.phone || '')
}
})
.catch(console.error)
}
// Fetch Orders
const fetchOrders = () => {
if (!customer) return
setLoadingOrders(true)
fetch(`${API_BASE}/orders/customer/${customer.id}`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
setOrders(data)
}
})
.catch(console.error)
.finally(() => setLoadingOrders(false))
}
// Fetch Addresses
const fetchAddresses = () => {
if (!customer) return
setLoadingAddresses(true)
fetch(`${API_BASE}/profile/${customer.id}/addresses`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
setAddresses(data)
}
})
.catch(console.error)
.finally(() => setLoadingAddresses(false))
}
useEffect(() => {
if (customer) {
fetchProfile()
fetchOrders()
fetchAddresses()
}
}, [customer])
// Reset Address Form
const resetAddressForm = () => {
setRecipientName('')
setPhone('')
setAddressLine('')
setSubdistrict('')
setDistrict('')
setProvince('')
setZipcode('')
setIsDefault(false)
setEditingAddress(null)
setShowAddressForm(false)
}
// Handle Edit Address Click
const startEditAddress = (addr: CustomerAddress) => {
setEditingAddress(addr)
setRecipientName(addr.recipientName)
setPhone(addr.phone)
setAddressLine(addr.addressLine)
setSubdistrict(addr.subdistrict)
setDistrict(addr.district)
setProvince(addr.province)
setZipcode(addr.zipcode)
setIsDefault(addr.isDefault)
setShowAddressForm(true)
}
// Handle Add/Edit Address Submission
const handleAddressSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!customer) return
setSubmittingAddress(true)
const payload = {
recipientName,
phone,
addressLine,
subdistrict,
district,
province,
zipcode,
isDefault
}
try {
const url = editingAddress
? `${API_BASE}/profile/${customer.id}/addresses/${editingAddress.id}`
: `${API_BASE}/profile/${customer.id}/addresses`
const method = editingAddress ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (res.ok) {
resetAddressForm()
fetchAddresses()
} else {
const err = await res.json()
alert(err.error || 'Failed to save address')
}
} catch (err) {
console.error(err)
alert('Failed to connect to server')
} finally {
setSubmittingAddress(false)
}
}
// Handle Delete Address
const handleDeleteAddress = async (addrId: number) => {
if (!customer) return
if (!confirm('ยืนยันที่จะลบที่อยู่จัดส่งนี้?')) return
try {
const res = await fetch(`${API_BASE}/profile/${customer.id}/addresses/${addrId}`, {
method: 'DELETE'
})
if (res.ok) {
fetchAddresses()
} else {
alert('Failed to delete address')
}
} catch (err) {
console.error(err)
}
}
// Handle Set Default Address
const handleSetDefault = async (addrId: number) => {
if (!customer) return
try {
const res = await fetch(`${API_BASE}/profile/${customer.id}/addresses/${addrId}/default`, {
method: 'PATCH'
})
if (res.ok) {
fetchAddresses()
}
} catch (err) {
console.error(err)
}
}
// Handle Settings Submit (Update email & phone)
const handleSettingsSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!customer) return
setSubmittingSettings(true)
setSettingsSuccess(false)
try {
const res = await fetch(`${API_BASE}/profile/${customer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: emailInput || null,
phone: phoneInput || null
})
})
if (res.ok) {
const updatedCustomer = await res.json()
setDbCustomer(updatedCustomer)
// Update store
if (token) {
setAuth(token, updatedCustomer)
}
setSettingsSuccess(true)
setTimeout(() => setSettingsSuccess(false), 3000)
} else {
const err = await res.json()
alert(err.error || 'Failed to update profile')
}
} catch (err) {
console.error(err)
alert('Failed to connect to server')
} finally {
setSubmittingSettings(false)
}
}
const getOrderStatusColor = (status: string) => {
switch (status.toUpperCase()) {
case 'PAID':
return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
case 'PENDING_PAYMENT':
return 'bg-amber-500/10 text-amber-600 border-amber-500/20'
case 'SHIPPED':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20'
case 'CANCELLED':
return 'bg-rose-500/10 text-rose-600 border-rose-500/20'
default:
return 'bg-spa-stone/10 text-spa-stone border-spa-stone/20'
}
}
const translateStatus = (status: string) => {
switch (status.toUpperCase()) {
case 'PAID':
return 'ชำระเงินสำเร็จ'
case 'PENDING_PAYMENT':
return 'รอการชำระเงิน'
case 'SHIPPED':
return 'จัดส่งแล้ว'
case 'CANCELLED':
return 'ยกเลิกแล้ว'
default:
return status
}
}
return (
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<main className="max-w-5xl mx-auto px-6 pt-32 pb-24 text-pop-black">
{!customer ? (
/* Login CTA screen */
<div className="max-w-md mx-auto text-center space-y-8 py-20 animate-fade-in bg-white p-10 rounded-3xl border border-spa-stone/10 shadow-sm mt-10">
<div className="w-20 h-20 bg-spa-light rounded-full flex items-center justify-center mx-auto border border-spa-stone/10 shadow-inner">
<User className="w-10 h-10 text-spa-stone" />
</div>
<div className="space-y-3">
<h1 className="text-3xl font-bold text-pop-black">เข้าสู่ระบบสมาชิก</h1>
<p className="text-spa-stone text-sm">ล็อกอินเพื่อตรวจสอบประวัติคำสั่งซื้อ สะสมคะแนนสมาชิก และบันทึกสมุดที่อยู่จัดส่งสินค้า</p>
</div>
<button
onClick={() => {
const liff = (window as any).liff
if (liff) {
liff.login({ redirectUri: window.location.href })
} else {
alert('LINE LIFF System is initializing. Please refresh.')
}
}}
className="w-full py-4 bg-spa-forest hover:bg-spa-stone text-white font-bold rounded-xl tracking-widest uppercase transition-all duration-300 shadow-sm flex items-center justify-center gap-3"
>
<Award className="w-5 h-5" />
<span>เข้าสู่ระบบผ่าน LINE</span>
</button>
<Link to="/shop" className="block text-xs text-spa-stone hover:underline font-bold">กลับไปเลือกซื้อสินค้า</Link>
</div>
) : (
/* Logged In Dashboard Layout */
<div className="space-y-10 animate-fade-in">
{/* Header Profile Banner */}
<div className="bg-white rounded-3xl border border-spa-stone/10 p-8 flex flex-col md:flex-row justify-between items-center gap-6 shadow-sm relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-1.5 bg-gradient-to-r from-spa-sage via-spa-forest to-spa-stone opacity-50" />
{/* User Avatar & Info */}
<div className="flex flex-col md:flex-row items-center gap-5 text-center md:text-left">
<div className="w-24 h-24 rounded-full overflow-hidden border-2 border-spa-forest/20 shadow-md">
<img
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="LINE Profile"
className="w-full h-full object-cover"
/>
</div>
<div className="space-y-1.5">
<h1 className="text-3xl font-bold text-pop-black">{customer.displayName}</h1>
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 text-xs font-semibold text-spa-stone">
{dbCustomer?.phone && (
<span className="flex items-center gap-1.5 justify-center md:justify-start">
<Phone className="w-3.5 h-3.5" />
<span>{dbCustomer.phone}</span>
</span>
)}
{dbCustomer?.email && (
<span className="flex items-center gap-1.5 justify-center md:justify-start">
<Mail className="w-3.5 h-3.5" />
<span>{dbCustomer.email}</span>
</span>
)}
</div>
</div>
</div>
{/* Point Cards */}
<div className="bg-gradient-to-br from-spa-forest to-spa-stone text-white p-6 rounded-2xl w-full md:w-auto min-w-[220px] text-center space-y-1.5 shadow-md border border-white/5 relative overflow-hidden">
<div className="absolute -right-8 -bottom-8 w-24 h-24 bg-white/5 rounded-full" />
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-sage">คะแนนสะสมของคุณ</p>
<div className="text-4xl font-extrabold flex items-center justify-center gap-1">
<span>{dbCustomer?.points ?? customer.points ?? 0}</span>
</div>
<p className="text-[10px] text-spa-sage/95 font-semibold">1 คะแนนมีค่าเท่ากับ ฿1 สำหรับใช้เป็นส่วนลด</p>
</div>
</div>
{/* Dashboard Tabs & Content */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
{/* Vertical Menu Tabs */}
<div className="bg-white rounded-2xl border border-spa-stone/10 p-3 shadow-sm space-y-1">
<button
onClick={() => setActiveTab('orders')}
className={`w-full py-3 px-4 rounded-xl font-bold text-left text-sm flex items-center gap-3 transition-all ${
activeTab === 'orders'
? 'bg-spa-forest text-white shadow-sm'
: 'text-spa-stone hover:bg-spa-light hover:text-pop-black'
}`}
>
<Package className="w-4.5 h-4.5" />
<span>ประวัติการสั่งซื้อ</span>
<span className="ml-auto bg-spa-sage/20 text-[10px] px-2 py-0.5 rounded-full font-extrabold text-spa-forest">
{orders.length}
</span>
</button>
<button
onClick={() => setActiveTab('addresses')}
className={`w-full py-3 px-4 rounded-xl font-bold text-left text-sm flex items-center gap-3 transition-all ${
activeTab === 'addresses'
? 'bg-spa-forest text-white shadow-sm'
: 'text-spa-stone hover:bg-spa-light hover:text-pop-black'
}`}
>
<MapPin className="w-4.5 h-4.5" />
<span>สมุดที่อยู่จัดส่ง</span>
<span className="ml-auto bg-spa-sage/20 text-[10px] px-2 py-0.5 rounded-full font-extrabold text-spa-forest">
{addresses.length}
</span>
</button>
<button
onClick={() => setActiveTab('settings')}
className={`w-full py-3 px-4 rounded-xl font-bold text-left text-sm flex items-center gap-3 transition-all ${
activeTab === 'settings'
? 'bg-spa-forest text-white shadow-sm'
: 'text-spa-stone hover:bg-spa-light hover:text-pop-black'
}`}
>
<Settings className="w-4.5 h-4.5" />
<span>ข้อมูลติดต่อของฉัน</span>
</button>
</div>
{/* Tab Display Area */}
<div className="lg:col-span-3 space-y-8">
{/* 1. ORDERS TAB */}
{activeTab === 'orders' && (
<div className="space-y-6">
<h2 className="text-xl font-bold border-b border-spa-stone/10 pb-4 flex items-center gap-2">
<Package className="w-5 h-5 text-spa-forest" />
<span>ประวัติคำสั่งซื้อทั้งหมด</span>
</h2>
{loadingOrders ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-spa-forest animate-spin" />
</div>
) : orders.length === 0 ? (
<div className="text-center py-16 bg-white rounded-2xl border border-spa-stone/10 space-y-4 shadow-sm">
<Package className="w-12 h-12 text-spa-stone/40 mx-auto" />
<div>
<p className="font-bold text-pop-black">ไม่มีประวัติการสั่งซื้อ</p>
<p className="text-xs text-spa-stone font-medium">คุณยังไม่ได้ทำการสั่งซื้อสินค้าใดๆ กับแบรนด์ของเรา</p>
</div>
<Link
to="/shop"
className="inline-block px-6 py-2.5 bg-spa-forest hover:bg-spa-stone text-white font-bold rounded-lg text-xs uppercase tracking-wider transition-colors shadow-sm"
>
ไปหน้าช้อปปิ้ง
</Link>
</div>
) : (
<div className="space-y-4">
{orders.map(order => (
<div
key={order.id}
className="bg-white border border-spa-stone/10 rounded-2xl p-6 shadow-sm hover:border-spa-stone transition-all duration-300 space-y-4"
>
{/* Card Header */}
<div className="flex flex-col md:flex-row justify-between md:items-center gap-3 border-b border-spa-stone/10 pb-4">
<div>
<p className="text-xs text-spa-stone font-semibold">เลขออเดอร์ #{order.id.toString().padStart(4, '0')}</p>
<p className="text-[10px] text-spa-stone/85 mt-0.5">สั่งเมื่อ {new Date(order.createdAt).toLocaleDateString('th-TH', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</p>
</div>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-bold border ${getOrderStatusColor(order.status)}`}>
{translateStatus(order.status)}
</span>
<Link
to={`/order/${order.id}`}
className="p-1.5 hover:bg-spa-light rounded-lg border border-spa-stone/10 transition-colors"
title="ดูหน้าออเดอร์สำเร็จ"
>
<ExternalLink className="w-4 h-4 text-spa-stone" />
</Link>
</div>
</div>
{/* Card Items */}
<div className="space-y-3">
{order.items.map(item => (
<div key={item.id} className="flex items-center gap-3 text-xs">
<div className="w-10 h-10 rounded-md bg-spa-stone/10 overflow-hidden border border-spa-stone/15 flex-shrink-0">
<img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-pop-black truncate">{item.productName}</p>
<p className="text-[10px] text-spa-stone/80 mt-0.5">{item.variantSize} x {item.quantity}</p>
</div>
<p className="font-bold text-rose-500 shrink-0">{formatPrice(item.priceAtPurchase * item.quantity)}</p>
</div>
))}
</div>
{/* Card Footer */}
<div className="border-t border-spa-stone/10 pt-4 flex flex-col md:flex-row justify-between items-start md:items-center gap-3 text-xs">
<div>
{order.trackingNo && (
<p className="font-semibold text-spa-stone">
เลขแทรกพัสดุ: <span className="font-mono text-spa-forest bg-spa-sage/15 px-2 py-0.5 rounded font-extrabold">{order.trackingNo}</span>
</p>
)}
</div>
<div className="flex items-center justify-between w-full md:w-auto gap-4 self-stretch md:self-auto border-t md:border-0 border-spa-stone/10 pt-3 md:pt-0">
<span className="text-spa-stone font-semibold">ยอดรวมสุทธิ:</span>
<span className="text-base font-extrabold text-rose-500">{formatPrice(order.totalAmount)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 2. ADDRESSES TAB */}
{activeTab === 'addresses' && (
<div className="space-y-6">
<div className="flex justify-between items-center border-b border-spa-stone/10 pb-4 gap-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<MapPin className="w-5 h-5 text-spa-forest" />
<span>สมุดที่อยู่จัดส่งของฉัน</span>
</h2>
{!showAddressForm && (
<button
onClick={() => {
resetAddressForm()
setShowAddressForm(true)
}}
className="px-4 py-2 bg-spa-forest hover:bg-spa-stone text-white font-bold rounded-lg text-xs uppercase tracking-wider transition-colors shadow-sm flex items-center gap-2"
>
<Plus className="w-3.5 h-3.5" />
<span>เพิ่มที่อยู่ใหม่</span>
</button>
)}
</div>
{/* Address Creation/Editing Form */}
{showAddressForm && (
<form onSubmit={handleAddressSubmit} className="bg-white border border-spa-stone/15 p-6 rounded-2xl shadow-sm space-y-5 animate-slide-down">
<h3 className="text-sm font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-2">
{editingAddress ? 'แก้ไขที่อยู่จัดส่ง' : 'เพิ่มที่อยู่จัดส่งใหม่'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">ชื่อผู้รับ</label>
<input
type="text"
required
value={recipientName}
onChange={e => setRecipientName(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
placeholder="เช่น สมชาย ใจดี"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">เบอร์โทรศัพท์ผู้รับ</label>
<input
type="tel"
required
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
placeholder="เช่น 0812345678"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">ที่อยู่ (บ้านเลขที่, หมู่บ้าน, ถนน, ซอย)</label>
<input
type="text"
required
value={addressLine}
onChange={e => setAddressLine(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
placeholder="เช่น 99/9 หมู่ 9 ซอยสุขุมวิท 23"
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">แขวง / ตำบล</label>
<input
type="text"
required
value={subdistrict}
onChange={e => setSubdistrict(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">เขต / อำเภอ</label>
<input
type="text"
required
value={district}
onChange={e => setDistrict(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">จังหวัด</label>
<input
type="text"
required
value={province}
onChange={e => setProvince(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">รหัสไปรษณีย์</label>
<input
type="text"
required
maxLength={5}
value={zipcode}
onChange={e => setZipcode(e.target.value.replace(/\D/g, ''))}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
</div>
</div>
<div className="flex items-center gap-2.5 py-1">
<input
type="checkbox"
id="isDefault"
checked={isDefault}
onChange={e => setIsDefault(e.target.checked)}
className="w-4 h-4 text-spa-forest focus:ring-spa-forest border-spa-stone/30 rounded cursor-pointer"
/>
<label htmlFor="isDefault" className="text-xs font-semibold text-spa-stone cursor-pointer select-none">
ตั้งเป็นที่อยู่จัดส่งเริ่มต้น (Default Address)
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-3 border-t border-spa-stone/10">
<button
type="button"
onClick={resetAddressForm}
className="px-5 py-2.5 border border-spa-stone/25 hover:bg-spa-light text-spa-stone font-bold rounded-lg text-xs uppercase tracking-wider transition-colors"
>
ยกเลิก
</button>
<button
type="submit"
disabled={submittingAddress}
className="px-5 py-2.5 bg-spa-forest hover:bg-spa-stone text-white font-bold rounded-lg text-xs uppercase tracking-wider transition-colors disabled:opacity-50 flex items-center gap-2"
>
{submittingAddress && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
<span>บันทึกที่อยู่</span>
</button>
</div>
</form>
)}
{loadingAddresses ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-spa-forest animate-spin" />
</div>
) : addresses.length === 0 ? (
<div className="text-center py-16 bg-white rounded-2xl border border-spa-stone/10 space-y-4 shadow-sm">
<MapPin className="w-12 h-12 text-spa-stone/40 mx-auto" />
<div>
<p className="font-bold text-pop-black">ไม่มีข้อมูลที่อยู่จัดส่ง</p>
<p className="text-xs text-spa-stone font-medium">กรุณากดปุ่มเพิ่มที่อยู่ด้านบนเพื่อบันทึกสมุดที่อยู่จัดส่งสินค้าของคุณ</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{addresses.map(addr => (
<div
key={addr.id}
className={`bg-white border rounded-2xl p-5 shadow-sm space-y-4 transition-all duration-300 relative ${
addr.isDefault
? 'border-spa-forest ring-1 ring-spa-forest/20'
: 'border-spa-stone/10 hover:border-spa-stone'
}`}
>
<div className="flex justify-between items-start gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-bold text-sm text-pop-black">{addr.recipientName}</p>
{addr.isDefault && (
<span className="bg-spa-forest/15 text-spa-forest text-[9px] font-extrabold px-2 py-0.5 rounded-full border border-spa-forest/20">
เริ่มต้น
</span>
)}
</div>
<p className="text-[10px] text-spa-stone font-semibold flex items-center gap-1">
<Phone className="w-3 h-3 text-spa-stone/60" />
<span>{addr.phone}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => startEditAddress(addr)}
className="p-1.5 hover:bg-spa-light rounded-lg border border-spa-stone/10 transition-colors"
title="แก้ไขที่อยู่"
>
<Edit3 className="w-3.5 h-3.5 text-spa-stone" />
</button>
<button
onClick={() => handleDeleteAddress(addr.id)}
className="p-1.5 hover:bg-rose-500/10 rounded-lg border border-rose-500/10 transition-colors"
title="ลบที่อยู่"
>
<Trash2 className="w-3.5 h-3.5 text-rose-500" />
</button>
</div>
</div>
<p className="text-xs text-spa-stone/90 leading-relaxed font-medium">
{addr.addressLine} ต.{addr.subdistrict} อ.{addr.district} จ.{addr.province} {addr.zipcode}
</p>
{!addr.isDefault && (
<button
onClick={() => handleSetDefault(addr.id)}
className="w-full py-1.5 border border-spa-stone/20 hover:bg-spa-light text-[10px] font-bold uppercase tracking-wider text-spa-stone rounded-lg transition-colors"
>
ตั้งเป็นที่อยู่เริ่มต้น
</button>
)}
</div>
))}
</div>
)}
</div>
)}
{/* 3. SETTINGS TAB */}
{activeTab === 'settings' && (
<div className="space-y-6">
<h2 className="text-xl font-bold border-b border-spa-stone/10 pb-4 flex items-center gap-2">
<Settings className="w-5 h-5 text-spa-forest" />
<span>ข้อมูลติดต่อสมาชิก</span>
</h2>
<form onSubmit={handleSettingsSubmit} className="bg-white border border-spa-stone/10 rounded-2xl p-6 shadow-sm max-w-lg space-y-5">
{settingsSuccess && (
<div className="p-4 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 font-bold rounded-xl text-xs text-center flex items-center justify-center gap-2">
<Check className="w-4 h-4" />
<span>บันทึกข้อมูลติดต่อของคุณสำเร็จแล้ว</span>
</div>
)}
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">ที่อยู่อีเมล (Email Address)</label>
<input
type="email"
value={emailInput}
onChange={e => setEmailInput(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-xs text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30"
placeholder="เช่น example@email.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">เบอร์โทรศัพท์ (Phone Number)</label>
<input
type="tel"
value={phoneInput}
onChange={e => setPhoneInput(e.target.value)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-xs text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30"
placeholder="เช่น 0812345678"
/>
</div>
<div className="pt-2">
<button
type="submit"
disabled={submittingSettings}
className="w-full py-3 bg-spa-forest hover:bg-spa-stone text-white font-bold rounded-lg text-xs uppercase tracking-widest transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
<span>บันทึกข้อมูลติดต่อ</span>
</button>
</div>
</form>
</div>
)}
</div>
</div>
</div>
)}
</main>
<Footer categories={categories} />
</div>
)
}
+17
View File
@@ -132,6 +132,7 @@ export interface Order {
shippingFee: number
status: string
paymentSlipUrl?: string | null
trackingNo?: string | null
customerId?: number | null
customer?: any | null
items: OrderItem[]
@@ -261,3 +262,19 @@ export interface EditorialCarouselSectionPayload {
}[]
}
export interface CustomerAddress {
id: number
customerId: number
recipientName: string
phone: string
addressLine: string
subdistrict: string
district: string
province: string
zipcode: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}