Files
dusuniot/src/routes/checkout.tsx
T

283 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 /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>
);
}