283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
||
* # 路由 /checkout 结账页
|
||
* 未登录:内联登录 / 注册表单(复用 ~/server/auth 的 loginFn/registerFn)。
|
||
* 已登录:账单信息表单 → createOrder(银行转账占位支付)→ 成功页展示订单号。
|
||
*/
|
||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||
import { useState } from "react";
|
||
import { getCart, createOrder, type BillingInfo } from "~/server/commerce";
|
||
import { useT } from "~/i18n";
|
||
import { getCurrentUser, loginFn, registerFn } from "~/server/auth";
|
||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Input } from "~/components/ui/input";
|
||
import { Label } from "~/components/ui/label";
|
||
import { Textarea } from "~/components/ui/textarea";
|
||
|
||
export const Route = createFileRoute("/checkout")({
|
||
loader: async () => {
|
||
const [layout, user, cart] = await Promise.all([getLayoutData(), getCurrentUser(), getCart()]);
|
||
return { layout, user, cart };
|
||
},
|
||
head: () => ({ meta: [{ title: "Checkout - DusunIoT" }] }),
|
||
component: CheckoutPage,
|
||
});
|
||
|
||
/** # 函数 组件 结账页面(外壳:公共布局) */
|
||
function CheckoutPage() {
|
||
const { layout } = Route.useLoaderData();
|
||
return (
|
||
<Layout layout={layout}>
|
||
<CheckoutInner />
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
/** # 函数 组件 结账表单区 */
|
||
function CheckoutInner() {
|
||
const t = useT();
|
||
const { user, cart } = Route.useLoaderData();
|
||
const router = useRouter();
|
||
const [authError, setAuthError] = useState("");
|
||
const [orderNumber, setOrderNumber] = useState("");
|
||
const [orderError, setOrderError] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
/** # 函数 登录提交(成功后 invalidate 以带会话重跑 loader) */
|
||
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
setAuthError("");
|
||
const fd = new FormData(e.currentTarget);
|
||
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? "") } });
|
||
if (!res.ok) return setAuthError(res.error);
|
||
router.invalidate();
|
||
}
|
||
|
||
/** # 函数 注册提交 */
|
||
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
setAuthError("");
|
||
const fd = new FormData(e.currentTarget);
|
||
const res = await registerFn({
|
||
data: {
|
||
email: String(fd.get("email") ?? ""),
|
||
password: String(fd.get("password") ?? ""),
|
||
displayName: String(fd.get("displayName") ?? "") || undefined,
|
||
},
|
||
});
|
||
if (!res.ok) return setAuthError(res.error);
|
||
router.invalidate();
|
||
}
|
||
|
||
/** # 函数 下单提交 */
|
||
async function handlePlaceOrder(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
setOrderError("");
|
||
setSubmitting(true);
|
||
const fd = new FormData(e.currentTarget);
|
||
const billing: BillingInfo = {
|
||
firstName: String(fd.get("firstName") ?? ""),
|
||
lastName: String(fd.get("lastName") ?? ""),
|
||
email: String(fd.get("email") ?? ""),
|
||
phone: String(fd.get("phone") ?? ""),
|
||
address: String(fd.get("address") ?? ""),
|
||
city: String(fd.get("city") ?? ""),
|
||
country: String(fd.get("country") ?? ""),
|
||
postcode: String(fd.get("postcode") ?? ""),
|
||
};
|
||
const shippingSame = fd.get("shippingSame") === "on";
|
||
const shipping = shippingSame
|
||
? undefined
|
||
: {
|
||
firstName: String(fd.get("shipFirstName") ?? ""),
|
||
lastName: String(fd.get("shipLastName") ?? ""),
|
||
email: billing.email,
|
||
address: String(fd.get("shipAddress") ?? ""),
|
||
city: String(fd.get("shipCity") ?? ""),
|
||
country: String(fd.get("shipCountry") ?? ""),
|
||
postcode: String(fd.get("shipPostcode") ?? ""),
|
||
};
|
||
const res = await createOrder({
|
||
data: { billing, shippingSame, shipping, note: String(fd.get("note") ?? "") },
|
||
});
|
||
setSubmitting(false);
|
||
if (!res.ok) return setOrderError(res.error);
|
||
setOrderNumber(res.orderNumber);
|
||
router.invalidate();
|
||
}
|
||
|
||
// 下单成功页
|
||
if (orderNumber) {
|
||
return (
|
||
<div className="site-container">
|
||
<h1 className="mt-5 mb-3.5 text-[26px]">Order received</h1>
|
||
<div className="max-w-[560px] rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||
Thank you! Your order <strong>{orderNumber}</strong> has been placed.
|
||
</div>
|
||
<p className="mt-4">
|
||
Payment method: bank transfer (our sales team will contact you with payment instructions).
|
||
</p>
|
||
<p className="flex gap-2.5">
|
||
<Button asChild>
|
||
<Link to="/my-account/">{t("checkout_view_orders")}</Link>
|
||
</Button>
|
||
<Button asChild variant="outline">
|
||
<Link to="/shop/">{t("cart_continue")}</Link>
|
||
</Button>
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 未登录:内联登录 / 注册
|
||
if (!user) {
|
||
return (
|
||
<div className="site-container">
|
||
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||
<p>
|
||
Please log in or create an account to place your order.{" "}
|
||
{cart.count > 0 && <>{t("checkout_cart_kept", { count: cart.count })}</>}
|
||
</p>
|
||
{authError && (
|
||
<p className="max-w-[420px] rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">
|
||
{authError}
|
||
</p>
|
||
)}
|
||
<div className="mt-4 grid grid-cols-1 items-start gap-7 min-[860px]:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
|
||
<div className="rounded-lg border border-line bg-soft p-5">
|
||
<h3 className="mt-0">Log in</h3>
|
||
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleLogin}>
|
||
<Label className="col-span-full">
|
||
Email
|
||
<Input name="email" type="email" required autoComplete="email" />
|
||
</Label>
|
||
<Label className="col-span-full">
|
||
Password
|
||
<Input name="password" type="password" required autoComplete="current-password" />
|
||
</Label>
|
||
<div className="col-span-full">
|
||
<Button type="submit">
|
||
Log in
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
<div className="rounded-lg border border-line bg-soft p-5">
|
||
<h3 className="mt-0">Create account</h3>
|
||
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleRegister}>
|
||
<Label className="col-span-full">
|
||
Display name (optional)
|
||
<Input name="displayName" type="text" />
|
||
</Label>
|
||
<Label className="col-span-full">
|
||
Email
|
||
<Input name="email" type="email" required autoComplete="email" />
|
||
</Label>
|
||
<Label className="col-span-full">
|
||
Password (min 8 chars)
|
||
<Input name="password" type="password" required minLength={8} autoComplete="new-password" />
|
||
</Label>
|
||
<div className="col-span-full">
|
||
<Button type="submit" variant="outline">
|
||
Register
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 购物车为空
|
||
if (cart.items.length === 0) {
|
||
return (
|
||
<div className="site-container">
|
||
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||
<div className="py-[60px] text-center text-muted-foreground">
|
||
<p>{t("cart_empty")}</p>
|
||
<Button asChild>
|
||
<Link to="/shop/">{t("checkout_browse")}</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 已登录:账单表单 + 订单汇总
|
||
return (
|
||
<div className="site-container">
|
||
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||
{orderError ? <p className="rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{orderError}</p> : null}
|
||
<div className="mt-4 grid grid-cols-1 items-start gap-7 min-[860px]:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
|
||
<form className="grid grid-cols-1 gap-3.5 rounded-lg border border-line bg-soft p-5 min-[640px]:grid-cols-2" onSubmit={handlePlaceOrder}>
|
||
<h2 className="col-span-full mt-0">Billing details</h2>
|
||
<Label>
|
||
First name *
|
||
<Input name="firstName" required />
|
||
</Label>
|
||
<Label>
|
||
Last name *
|
||
<Input name="lastName" required />
|
||
</Label>
|
||
<Label>
|
||
Email *
|
||
<Input name="email" type="email" required defaultValue={user.email} />
|
||
</Label>
|
||
<Label>
|
||
Phone
|
||
<Input name="phone" type="tel" />
|
||
</Label>
|
||
<Label className="col-span-full">
|
||
Street address *
|
||
<Input name="address" required />
|
||
</Label>
|
||
<Label>
|
||
City *
|
||
<Input name="city" required />
|
||
</Label>
|
||
<Label>
|
||
Country *
|
||
<Input name="country" required />
|
||
</Label>
|
||
<Label>
|
||
Postcode
|
||
<Input name="postcode" />
|
||
</Label>
|
||
<Label className="col-span-full flex-row items-center gap-2">
|
||
<input name="shippingSame" type="checkbox" defaultChecked />
|
||
<span>{t("checkout_same_address")}</span>
|
||
</Label>
|
||
<Label className="col-span-full">
|
||
Order notes
|
||
<Textarea name="note" rows={3} placeholder={t("checkout_notes_ph")} />
|
||
</Label>
|
||
<div className="col-span-full">
|
||
<Button type="submit" disabled={submitting}>
|
||
{submitting ? "Placing order…" : "Place order"}
|
||
</Button>
|
||
<span className="text-[13px] text-muted-foreground">Payment: bank transfer (pay after confirmation)</span>
|
||
</div>
|
||
</form>
|
||
<div className="rounded-lg border border-line bg-soft p-5">
|
||
<h3 className="mt-0">Your order</h3>
|
||
<div className="overflow-hidden rounded-md border border-line bg-white">
|
||
{cart.items.map((line) => (
|
||
<div className="flex justify-between gap-3 border-b border-line px-3.5 py-2.5 text-sm" key={line.id}>
|
||
<span>
|
||
{line.title} × {line.qty}
|
||
</span>
|
||
<span>${(Number(line.price) * line.qty).toFixed(2)}</span>
|
||
</div>
|
||
))}
|
||
<div className="flex justify-between gap-3 px-3.5 py-2.5 text-sm font-bold">
|
||
<span>{t("checkout_subtotal")}</span>
|
||
<span>${cart.subtotal}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|