feat: implement user profile, order history, and address book with checkout integration
This commit is contained in:
1 parent
bae945b9d4
commit
0c3c450e1f
9 files changed
+1109
-32
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;
|
||||
@@ -76,17 +76,34 @@ model User {
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id Int @id @default(autoincrement())
|
||||
lineUserId String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
lineUserId String @unique
|
||||
displayName String?
|
||||
pictureUrl String?
|
||||
email String?
|
||||
phone String?
|
||||
points Int @default(0)
|
||||
points Int @default(0)
|
||||
orders Order[]
|
||||
reviews Review[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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
|
||||
}
|
||||
|
||||
model Hero {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -183,20 +183,28 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
|
||||
{customer ? (
|
||||
<div className="relative group flex items-center">
|
||||
<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"
|
||||
/>
|
||||
<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 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 animate-fade-in"
|
||||
/>
|
||||
</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>
|
||||
@@ -212,17 +220,18 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── MOBILE RIGHT ACTIONS ── */}
|
||||
<div className="flex md:hidden items-center gap-1.5">
|
||||
{customer ? (
|
||||
<div className="relative">
|
||||
<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 to="/profile">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
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') },
|
||||
|
||||
@@ -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,20 +48,42 @@ 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];
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: prev.name || lastOrder.customerName || '',
|
||||
phone: prev.phone || lastOrder.customerPhone || '',
|
||||
email: prev.email || lastOrder.customerEmail || '',
|
||||
address: prev.address || lastOrder.shippingAddress || ''
|
||||
}))
|
||||
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 || '',
|
||||
phone: prev.phone || lastOrder.customerPhone || '',
|
||||
email: prev.email || lastOrder.customerEmail || '',
|
||||
address: prev.address || lastOrder.shippingAddress || ''
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user