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
+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;
|
||||||
@@ -85,6 +85,23 @@ model Customer {
|
|||||||
points Int @default(0)
|
points Int @default(0)
|
||||||
orders Order[]
|
orders Order[]
|
||||||
reviews Review[]
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { reviewsRoute } from "./routes/reviews";
|
|||||||
import { promotionsRoute } from "./routes/promotions";
|
import { promotionsRoute } from "./routes/promotions";
|
||||||
import { pagesRoute } from "./routes/pages";
|
import { pagesRoute } from "./routes/pages";
|
||||||
import { paymentsRoute } from "./routes/payments";
|
import { paymentsRoute } from "./routes/payments";
|
||||||
|
import { profileRoute } from "./routes/profile";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||||
@@ -85,7 +86,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.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);
|
.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 TrackOrderPage from './pages/TrackOrderPage'
|
||||||
import WishlistPage from './pages/WishlistPage'
|
import WishlistPage from './pages/WishlistPage'
|
||||||
import CustomPage from './pages/CustomPage'
|
import CustomPage from './pages/CustomPage'
|
||||||
|
import ProfilePage from './pages/ProfilePage'
|
||||||
import CartDrawer from './components/CartDrawer'
|
import CartDrawer from './components/CartDrawer'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -52,6 +53,7 @@ function App() {
|
|||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/pages/:slug" element={<CustomPage />} />
|
<Route path="/pages/:slug" element={<CustomPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -183,20 +183,28 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
|||||||
|
|
||||||
{customer ? (
|
{customer ? (
|
||||||
<div className="relative group flex items-center">
|
<div className="relative group flex items-center">
|
||||||
|
<Link to="/profile">
|
||||||
<img
|
<img
|
||||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||||
alt="Profile"
|
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="p-1">
|
||||||
<div className="px-3 py-2 border-b border-spa-stone/10 mb-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-[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>
|
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={logoutLiff}
|
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
|
Logout
|
||||||
</button>
|
</button>
|
||||||
@@ -217,12 +225,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
|||||||
<div className="flex md:hidden items-center gap-1.5">
|
<div className="flex md:hidden items-center gap-1.5">
|
||||||
{customer ? (
|
{customer ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<Link to="/profile">
|
||||||
<img
|
<img
|
||||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
onClick={() => setMobileOpen(true)}
|
|
||||||
className="w-7 h-7 rounded-full border-2 border-spa-forest/30 object-cover cursor-pointer"
|
className="w-7 h-7 rounded-full border-2 border-spa-forest/30 object-cover cursor-pointer"
|
||||||
/>
|
/>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -334,6 +343,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
|||||||
{[
|
{[
|
||||||
{ to: '/', label: t('nav.home'), end: true },
|
{ to: '/', label: t('nav.home'), end: true },
|
||||||
{ to: '/shop', label: t('nav.shop') },
|
{ to: '/shop', label: t('nav.shop') },
|
||||||
|
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
||||||
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
|
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
|
||||||
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
|
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
|
||||||
{ to: '/about', label: t('nav.about') },
|
{ to: '/about', label: t('nav.about') },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
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 { useCartStore } from '../store/cartStore'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
@@ -36,6 +36,7 @@ export default function CheckoutPage() {
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: ''
|
address: ''
|
||||||
})
|
})
|
||||||
|
const [savedAddresses, setSavedAddresses] = useState<CustomerAddress[]>([])
|
||||||
|
|
||||||
// Prefill form with LINE customer data and last used address
|
// Prefill form with LINE customer data and last used address
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,12 +48,32 @@ export default function CheckoutPage() {
|
|||||||
phone: prev.phone || customer.phone || ''
|
phone: prev.phone || customer.phone || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Fetch latest order to pre-fill address acting as an automatic Address Book
|
// Fetch saved addresses first
|
||||||
fetch(`${API_BASE}/orders/customer/${customer.id}`)
|
fetch(`${API_BASE}/profile/${customer.id}/addresses`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data)) {
|
||||||
const lastOrder = data[0];
|
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 => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
name: prev.name || lastOrder.customerName || '',
|
name: prev.name || lastOrder.customerName || '',
|
||||||
@@ -63,6 +84,8 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
|
||||||
fetch(`${API_BASE}/customers/${customer.id}`)
|
fetch(`${API_BASE}/customers/${customer.id}`)
|
||||||
.then(res => res.json())
|
.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"
|
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..."
|
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>
|
</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
|
shippingFee: number
|
||||||
status: string
|
status: string
|
||||||
paymentSlipUrl?: string | null
|
paymentSlipUrl?: string | null
|
||||||
|
trackingNo?: string | null
|
||||||
customerId?: number | null
|
customerId?: number | null
|
||||||
customer?: any | null
|
customer?: any | null
|
||||||
items: OrderItem[]
|
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