x
This commit is contained in:
1 parent
c6bf2f70ac
commit
d3e817d8ba
4 files changed
+499
-13
No files matched your search
@@ -21,6 +21,7 @@ import { couponsRoute } from "./routes/coupons";
|
||||
import { reviewsRoute } from "./routes/reviews";
|
||||
import { promotionsRoute } from "./routes/promotions";
|
||||
import { pagesRoute } from "./routes/pages";
|
||||
import { paymentsRoute } from "./routes/payments";
|
||||
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";
|
||||
@@ -84,7 +85,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)
|
||||
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)
|
||||
)
|
||||
.listen(PORT);
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
const OMISE_SECRET_KEY = process.env.OMISE_SECRET_KEY || "";
|
||||
|
||||
export const paymentsRoute = new Elysia({ prefix: "/payments" })
|
||||
.post("/charge", async ({ body, set }) => {
|
||||
try {
|
||||
const { orderId, amount, type, token } = body as {
|
||||
orderId: number;
|
||||
amount: number;
|
||||
type: "card" | "promptpay";
|
||||
token?: string;
|
||||
};
|
||||
|
||||
if (!OMISE_SECRET_KEY) {
|
||||
set.status = 500;
|
||||
return { error: "Omise secret key is not configured" };
|
||||
}
|
||||
|
||||
// 1. Check if order exists
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId }
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
|
||||
// Convert amount to satang (integer)
|
||||
const amountInSatang = Math.round(amount * 100);
|
||||
|
||||
// Setup Basic Auth header for Omise
|
||||
const authHeader = "Basic " + Buffer.from(OMISE_SECRET_KEY + ":").toString("base64");
|
||||
|
||||
if (type === "card") {
|
||||
if (!token) {
|
||||
set.status = 400;
|
||||
return { error: "Card token is required for card payments" };
|
||||
}
|
||||
|
||||
// Create a card charge directly
|
||||
const chargeRes = await fetch("https://api.omise.co/charges", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": authHeader,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountInSatang,
|
||||
currency: "THB",
|
||||
card: token,
|
||||
metadata: {
|
||||
orderId: String(orderId)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const charge = await chargeRes.json() as any;
|
||||
|
||||
if (charge.error) {
|
||||
set.status = 400;
|
||||
return { error: charge.message || "Failed to create credit card charge" };
|
||||
}
|
||||
|
||||
// If card payment is immediately successful (some cards don't require 3DS)
|
||||
if (charge.status === "successful") {
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: "PAID" }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: charge.status,
|
||||
chargeId: charge.id,
|
||||
authorizeUri: charge.authorize_uri || null // For 3DS card validation redirect
|
||||
};
|
||||
} else {
|
||||
// PromptPay:
|
||||
// 1. Create source
|
||||
const sourceRes = await fetch("https://api.omise.co/sources", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": authHeader,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "promptpay",
|
||||
amount: amountInSatang,
|
||||
currency: "THB"
|
||||
})
|
||||
});
|
||||
const source = await sourceRes.json() as any;
|
||||
if (source.error) {
|
||||
set.status = 400;
|
||||
return { error: source.message || "Failed to create PromptPay source" };
|
||||
}
|
||||
|
||||
// 2. Create charge
|
||||
const chargeRes = await fetch("https://api.omise.co/charges", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": authHeader,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountInSatang,
|
||||
currency: "THB",
|
||||
source: source.id,
|
||||
metadata: {
|
||||
orderId: String(orderId)
|
||||
}
|
||||
})
|
||||
});
|
||||
const charge = await chargeRes.json() as any;
|
||||
if (charge.error) {
|
||||
set.status = 400;
|
||||
return { error: charge.message || "Failed to create PromptPay charge" };
|
||||
}
|
||||
|
||||
// Get QR Code Image URL from the charge's source references
|
||||
const qrCodeUrl = charge.source?.references?.barcode || null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: charge.status,
|
||||
chargeId: charge.id,
|
||||
qrCodeUrl: qrCodeUrl
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[Payment Charge] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to process payment request" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
orderId: t.Number(),
|
||||
amount: t.Number(),
|
||||
type: t.Union([t.Literal("card"), t.Literal("promptpay")]),
|
||||
token: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.post("/webhook", async ({ body, set }) => {
|
||||
try {
|
||||
const event = body as any;
|
||||
console.log(`[Omise Webhook] Received event: ${event.key}`);
|
||||
|
||||
if (event.key === "charge.complete") {
|
||||
const charge = event.data;
|
||||
const status = charge.status;
|
||||
const orderIdStr = charge.metadata?.orderId;
|
||||
|
||||
if (orderIdStr && status === "successful") {
|
||||
const orderId = parseInt(orderIdStr, 10);
|
||||
console.log(`[Omise Webhook] Payment successful for Order #${orderId}`);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: "PAID" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
} catch (err: any) {
|
||||
console.error("[Omise Webhook] Error processing event:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to process webhook" };
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>website</title>
|
||||
<script src="https://cdn.omise.co/omise.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||
import { API_BASE, formatPrice, type Order, type StoreSettings, type Category, type PageSection } from '../types'
|
||||
import { CheckCircle2, UploadCloud, Loader2, Copy } from 'lucide-react'
|
||||
import { CheckCircle2, UploadCloud, Loader2, Copy, CreditCard, QrCode, Landmark } from 'lucide-react'
|
||||
|
||||
export default function OrderSuccessPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -17,6 +17,135 @@ export default function OrderSuccessPage() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
|
||||
const [paymentMethod, setPaymentMethod] = useState<'bank' | 'promptpay' | 'card'>('bank')
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
|
||||
const [generatingQr, setGeneratingQr] = useState(false)
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
// Card Form State
|
||||
const [cardNumber, setCardNumber] = useState('')
|
||||
const [cardName, setCardName] = useState('')
|
||||
const [cardExpiry, setCardExpiry] = useState('') // MM/YY
|
||||
const [cardCvv, setCardCvv] = useState('')
|
||||
const [payingCard, setPayingCard] = useState(false)
|
||||
const [cardError, setCardError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
if (polling && order && order.status === 'PENDING_PAYMENT') {
|
||||
interval = setInterval(() => {
|
||||
fetch(`${API_BASE}/orders/${order.id}`)
|
||||
.then(res => res.json())
|
||||
.then(updatedOrder => {
|
||||
if (updatedOrder && updatedOrder.status === 'PAID') {
|
||||
setOrder(updatedOrder);
|
||||
setPolling(false);
|
||||
clearInterval(interval);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, 5000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [polling, order]);
|
||||
|
||||
const handleGeneratePromptPay = async () => {
|
||||
if (!order) return
|
||||
setGeneratingQr(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/payments/charge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orderId: order.id,
|
||||
amount: order.totalAmount,
|
||||
type: 'promptpay'
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success && data.qrCodeUrl) {
|
||||
setQrCodeUrl(data.qrCodeUrl)
|
||||
setPolling(true)
|
||||
} else {
|
||||
alert(data.error || 'Failed to generate PromptPay QR')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Failed to connect to payment server')
|
||||
} finally {
|
||||
setGeneratingQr(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardPay = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!order) return
|
||||
setPayingCard(true)
|
||||
setCardError(null)
|
||||
|
||||
try {
|
||||
const omise = (window as any).Omise
|
||||
if (!omise) {
|
||||
throw new Error('Omise library is loading, please try again.')
|
||||
}
|
||||
|
||||
omise.setPublicKey(import.meta.env.VITE_OMISE_PUBLIC_KEY)
|
||||
|
||||
const parts = cardExpiry.split('/')
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Expiry date must be in MM/YY format')
|
||||
}
|
||||
const expMonth = parseInt(parts[0], 10)
|
||||
let expYear = parseInt(parts[1], 10)
|
||||
if (expYear < 100) expYear += 2000
|
||||
|
||||
const cardData = {
|
||||
name: cardName,
|
||||
number: cardNumber.replace(/\s+/g, ''),
|
||||
expiration_month: expMonth,
|
||||
expiration_year: expYear,
|
||||
security_code: cardCvv
|
||||
}
|
||||
|
||||
omise.createToken('card', cardData, async (statusCode: number, response: any) => {
|
||||
if (statusCode === 200) {
|
||||
try {
|
||||
const chargeRes = await fetch(`${API_BASE}/payments/charge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orderId: order.id,
|
||||
amount: order.totalAmount,
|
||||
type: 'card',
|
||||
token: response.id
|
||||
})
|
||||
})
|
||||
const chargeData = await chargeRes.json()
|
||||
if (chargeRes.ok && chargeData.success) {
|
||||
if (chargeData.authorizeUri) {
|
||||
window.location.href = chargeData.authorizeUri
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} else {
|
||||
setCardError(chargeData.error || 'Card charge request failed')
|
||||
setPayingCard(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCardError('Failed to verify card charge on server')
|
||||
setPayingCard(false)
|
||||
}
|
||||
} else {
|
||||
setCardError(response.message || 'Validation failed for card details')
|
||||
setPayingCard(false)
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
setCardError(err.message || 'An error occurred during payment processing')
|
||||
setPayingCard(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/orders/${id}`).then(r => r.json()),
|
||||
@@ -159,16 +288,52 @@ export default function OrderSuccessPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banking Details */}
|
||||
{settings && order.status === 'PENDING_PAYMENT' && (
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 text-center space-y-6 relative overflow-hidden shadow-sm">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-spa-forest to-transparent opacity-50" />
|
||||
|
||||
<h2 className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">Payment Required</h2>
|
||||
<div className="text-3xl font-bold text-spa-forest">
|
||||
{formatPrice(order.totalAmount)}
|
||||
{order.status === 'PENDING_PAYMENT' ? (
|
||||
<div className="space-y-8">
|
||||
{/* Payment Method Selector */}
|
||||
<div className="bg-white p-4 rounded-2xl border border-spa-stone/10 shadow-sm flex flex-col md:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => setPaymentMethod('bank')}
|
||||
className={`flex-1 py-4 px-6 rounded-xl font-bold flex items-center justify-center gap-3 border transition-all ${
|
||||
paymentMethod === 'bank'
|
||||
? 'bg-spa-forest text-white border-spa-forest shadow-sm'
|
||||
: 'bg-white text-spa-stone hover:bg-spa-light border-spa-stone/10'
|
||||
}`}
|
||||
>
|
||||
<Landmark className="w-5 h-5" />
|
||||
<span>โอนเงินธนาคาร</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPaymentMethod('promptpay')}
|
||||
className={`flex-1 py-4 px-6 rounded-xl font-bold flex items-center justify-center gap-3 border transition-all ${
|
||||
paymentMethod === 'promptpay'
|
||||
? 'bg-spa-forest text-white border-spa-forest shadow-sm'
|
||||
: 'bg-white text-spa-stone hover:bg-spa-light border-spa-stone/10'
|
||||
}`}
|
||||
>
|
||||
<QrCode className="w-5 h-5" />
|
||||
<span>PromptPay QR</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className={`flex-1 py-4 px-6 rounded-xl font-bold flex items-center justify-center gap-3 border transition-all ${
|
||||
paymentMethod === 'card'
|
||||
? 'bg-spa-forest text-white border-spa-forest shadow-sm'
|
||||
: 'bg-white text-spa-stone hover:bg-spa-light border-spa-stone/10'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
<span>บัตรเครดิต/เดบิต</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Layout */}
|
||||
{paymentMethod === 'bank' && settings && (
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 text-center space-y-6 relative overflow-hidden shadow-sm">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-spa-forest to-transparent opacity-50" />
|
||||
<h2 className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">Payment Required</h2>
|
||||
<div className="text-3xl font-bold text-spa-forest">{formatPrice(order.totalAmount)}</div>
|
||||
<div className="bg-spa-light p-6 rounded-xl border border-spa-stone/20 inline-block text-left w-full max-w-md mx-auto space-y-4 shadow-inner">
|
||||
<div>
|
||||
<p className="text-xs text-spa-stone/80 uppercase tracking-widest font-bold">Bank</p>
|
||||
@@ -194,12 +359,9 @@ export default function OrderSuccessPage() {
|
||||
</div>
|
||||
<p className="text-xs text-spa-stone font-medium">Please transfer the exact amount and upload your slip below.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Status */}
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Payment Slip Verification</h2>
|
||||
|
||||
{order.paymentSlipUrl ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 py-4">
|
||||
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center border border-emerald-500/20">
|
||||
@@ -235,6 +397,155 @@ export default function OrderSuccessPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PromptPay Layout */}
|
||||
{paymentMethod === 'promptpay' && (
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 text-center space-y-6 relative overflow-hidden shadow-sm">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-spa-forest to-transparent opacity-50" />
|
||||
<h2 className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">PromptPay QR Code</h2>
|
||||
<div className="text-3xl font-bold text-spa-forest">{formatPrice(order.totalAmount)}</div>
|
||||
|
||||
{!qrCodeUrl ? (
|
||||
<div className="py-8">
|
||||
<button
|
||||
onClick={handleGeneratePromptPay}
|
||||
disabled={generatingQr}
|
||||
className="px-8 py-4 bg-spa-forest text-white font-bold rounded-xl tracking-widest uppercase hover:bg-spa-stone transition-all duration-300 disabled:opacity-50 flex items-center justify-center gap-3 mx-auto"
|
||||
>
|
||||
{generatingQr ? <Loader2 className="w-5 h-5 animate-spin" /> : <QrCode className="w-5 h-5" />}
|
||||
<span>สร้างคิวอาร์โค้ด PromptPay</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 max-w-sm mx-auto">
|
||||
<div className="bg-white p-4 rounded-xl border border-spa-stone/20 inline-block shadow-sm">
|
||||
<img src={qrCodeUrl} alt="PromptPay QR Code" className="w-64 h-64 object-contain mx-auto" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-spa-stone font-medium">
|
||||
<p className="text-sm font-bold text-pop-black">สแกนจ่ายเงินเพื่อเสร็จสิ้นออเดอร์</p>
|
||||
<p>1. กดบันทึกรูปภาพคิวอาร์โค้ดลงในมือถือของคุณ</p>
|
||||
<p>2. เปิดแอป Mobile Banking และเลือกสแกนภาพจากอัลบั้ม</p>
|
||||
<p className="text-[10px] text-rose-500 uppercase font-bold tracking-wider animate-pulse mt-4">
|
||||
{polling ? "🔄 กำลังตรวจสอบยอดชำระเงินโดยอัตโนมัติ..." : "รอการจ่ายเงิน..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={qrCodeUrl}
|
||||
download={`promptpay-order-${order.id}.png`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-xs text-spa-forest hover:underline font-bold"
|
||||
>
|
||||
<UploadCloud className="w-4 h-4 transform rotate-180" />
|
||||
<span>ดาวน์โหลดรูปภาพ QR Code</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credit Card Layout */}
|
||||
{paymentMethod === 'card' && (
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 relative overflow-hidden shadow-sm">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-spa-forest to-transparent opacity-50" />
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">Credit / Debit Card</h2>
|
||||
<div className="text-3xl font-bold text-spa-forest">{formatPrice(order.totalAmount)}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCardPay} className="max-w-md mx-auto space-y-5">
|
||||
{cardError && (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 font-medium rounded-lg text-sm text-center">
|
||||
{cardError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">ชื่อบนบัตร (Cardholder Name)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={cardName}
|
||||
onChange={e => setCardName(e.target.value)}
|
||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30"
|
||||
placeholder="Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">หมายเลขบัตร (Card Number)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={19}
|
||||
value={cardNumber}
|
||||
onChange={e => {
|
||||
const val = e.target.value.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').trim();
|
||||
setCardNumber(val);
|
||||
}}
|
||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30"
|
||||
placeholder="4242 4242 4242 4242"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">วันหมดอายุ (Expiry Date)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={5}
|
||||
value={cardExpiry}
|
||||
onChange={e => {
|
||||
const val = e.target.value.replace(/\D/g, '').replace(/(\d{2})(\d{1,2})/, '$1/$2').trim();
|
||||
setCardExpiry(val);
|
||||
}}
|
||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30"
|
||||
placeholder="MM/YY"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">รหัส CVV / CVC</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
maxLength={4}
|
||||
value={cardCvv}
|
||||
onChange={e => setCardCvv(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30"
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={payingCard}
|
||||
className="w-full py-4 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone transition-all duration-300 disabled:opacity-50 flex items-center justify-center gap-2 mt-4"
|
||||
>
|
||||
{payingCard ? <Loader2 className="w-5 h-5 animate-spin" /> : <CreditCard className="w-5 h-5" />}
|
||||
<span>ชำระเงิน {formatPrice(order.totalAmount)}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 text-center space-y-6 shadow-sm">
|
||||
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center border border-emerald-500/20 mx-auto">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-bold text-2xl text-emerald-600">ชำระเงินเสร็จสิ้น</p>
|
||||
<p className="text-sm text-spa-stone font-medium">ทางแบรนด์ได้รับยอดชำระของคุณเรียบร้อย ออเดอร์กำลังอยู่ระหว่างจัดเตรียมสินค้า</p>
|
||||
<p className="text-xs text-spa-stone/60">ออเดอร์สถานะปัจจุบัน: {order.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
Reference in new issue
Block a user