feat : update

This commit is contained in:
2026-10-03 00:56:31 +07:00 Unverified
parent f71c00d2ab
commit f23b69a280
36 changed files with 766 additions and 376 deletions
+139 -67
View File
@@ -10,20 +10,41 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/client";
declare global {
interface Window {
turnstile?: {
render(container: HTMLElement, options: {
sitekey: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
}): string;
reset(widgetId: string): void;
};
}
}
export function AccountForm({
mode,
siteKey,
nextPath,
verified,
verificationError,
}: {
mode: "login" | "register";
siteKey: string;
nextPath: string;
verified?: boolean;
verificationError?: boolean;
}) {
const router = useRouter();
const register = mode === "register";
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pendingEmail, setPendingEmail] = useState("");
const [notice, setNotice] = useState("");
const [token, setToken] = useState("");
const formRef = useRef<HTMLFormElement>(null);
const container = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
const captcha = process.env.NODE_ENV !== "development";
@@ -48,47 +69,40 @@ export function AccountForm({
};
}, [captcha, siteKey]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (captcha && !token) return;
async function sendRegistrationOtp() {
if (!register || busy) return;
if (pendingEmail) {
setBusy(true);
setError("");
setNotice("");
try {
const result = await authClient.emailOtp.sendVerificationOtp({
email: pendingEmail, type: "email-verification",
});
if (result.error) throw new Error("ส่งรหัสใหม่ไม่สำเร็จ กรุณาลองอีกครั้ง");
setNotice("ส่งรหัสใหม่แล้ว กรุณาตรวจสอบอีเมลของคุณ");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ส่งรหัสใหม่ไม่สำเร็จ");
} finally { setBusy(false); }
return;
}
const form = formRef.current;
if (!form?.reportValidity() || (captcha && !token)) return;
setBusy(true);
setError("");
const form = new FormData(event.currentTarget);
const email = String(form.get("email") ?? "").trim();
const password = String(form.get("password") ?? "");
const image = register ? form.get("image") : null;
const fetchOptions = {
headers: captcha ? { "x-captcha-response": token } : undefined,
};
setNotice("");
try {
if (image instanceof File && image.size > 0 &&
(image.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(image.type))) {
throw new Error("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
}
const result = register
? await authClient.signUp.email({
name: String(form.get("name") ?? "").trim(),
email,
password,
fetchOptions,
})
: await authClient.signIn.email({ email, password, fetchOptions });
if (result.error)
throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ");
if (register && image instanceof File && image.size > 0) {
const profile = new FormData();
profile.set("name", String(form.get("name") ?? "").trim());
profile.set("image", image);
const uploaded = await fetch("/api/profile", { method: "POST", body: profile })
.then((response) => response.ok).catch(() => false);
if (!uploaded) {
router.push(`/profile?setup=1&upload=failed&next=${encodeURIComponent(nextPath)}`);
router.refresh();
return;
}
}
router.push(nextPath);
router.refresh();
const fields = new FormData(form);
const email = String(fields.get("email") ?? "").trim();
const result = await authClient.signUp.email({
name: String(fields.get("name") ?? "").trim(),
email,
password: String(fields.get("password") ?? ""),
fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined },
});
if (result.error) throw new Error(result.error.message ?? "ส่งรหัสไม่สำเร็จ กรุณาลองอีกครั้ง");
setPendingEmail(email);
setNotice("ส่งรหัส 6 หลักแล้ว กรุณาตรวจสอบอีเมลและกรอกรหัสภายใน 5 นาที");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
setToken("");
@@ -98,13 +112,59 @@ export function AccountForm({
}
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (register && !pendingEmail) return sendRegistrationOtp();
if (busy || (!register && captcha && !token)) return;
setBusy(true);
setError("");
try {
if (register) {
const form = new FormData(event.currentTarget);
const result = await authClient.emailOtp.verifyEmail({
email: pendingEmail, otp: String(form.get("otp") ?? "").trim(),
});
if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง");
const setupPath = `/profile?setup=1&next=${encodeURIComponent(nextPath)}`;
router.push(`/login?verified=1&next=${encodeURIComponent(setupPath)}`);
router.refresh();
return;
}
const form = new FormData(event.currentTarget);
const email = String(form.get("email") ?? "").trim();
const result = await authClient.signIn.email({
email, password: String(form.get("password") ?? ""),
fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined },
});
if (result.error?.code === "EMAIL_NOT_VERIFIED") {
router.push(`/check-email?email=${encodeURIComponent(email)}&next=${encodeURIComponent(nextPath)}`);
return;
}
if (result.error) throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ");
router.push(nextPath);
router.refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
if (!register) {
setToken("");
if (widget.current) window.turnstile?.reset(widget.current);
}
} finally { setBusy(false); }
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<form onSubmit={submit}>
{verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>}
{verificationError && <Alert variant="destructive"><AlertDescription>ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่</AlertDescription></Alert>}
{notice && <Alert><AlertDescription>{notice}</AlertDescription></Alert>}
{register && !pendingEmail && <p className="text-sm text-muted-foreground">
กรอกชื่อ อีเมล และรหัสผ่าน แล้วกดส่ง OTP
</p>}
<form ref={formRef} onSubmit={submit}>
<FieldGroup>
{register && (
<Field>
@@ -116,24 +176,27 @@ export function AccountForm({
minLength={2}
maxLength={80}
autoComplete="name"
disabled={busy || Boolean(pendingEmail)}
/>
</Field>
)}
{register && (
<Field>
<FieldLabel htmlFor="register-image">รูปโปรไฟล์ (ไม่บังคับ)</FieldLabel>
<Input id="register-image" name="image" type="file" accept="image/png,image/jpeg,image/webp" />
</Field>
)}
<Field>
<FieldLabel htmlFor="email">อีเมล</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
/>
<div className="flex items-center gap-2">
<Input
id="email"
name="email"
type="email"
className="min-w-0 flex-1"
required
autoComplete="email"
disabled={busy || Boolean(pendingEmail)}
/>
{register && <Button type="button" variant="outline" className="shrink-0"
onClick={sendRegistrationOtp} disabled={busy || (captcha && !token && !pendingEmail)}>
{busy ? "กำลังส่ง..." : pendingEmail ? "ส่งอีกครั้ง" : "ส่ง OTP"}
</Button>}
</div>
</Field>
<Field>
<FieldLabel htmlFor="password">รหัสผ่าน</FieldLabel>
@@ -144,29 +207,38 @@ export function AccountForm({
required
minLength={6}
autoComplete={register ? "new-password" : "current-password"}
disabled={busy || Boolean(pendingEmail)}
/>
</Field>
{captcha && <div ref={container} />}
<Button type="submit" disabled={busy || (captcha && !token)}>
{register && <Field>
<FieldLabel htmlFor="otp">รหัส OTP</FieldLabel>
<Input id="otp" name="otp" inputMode="numeric" autoComplete="one-time-code"
pattern="[0-9]{6}" minLength={6} maxLength={6} required={Boolean(pendingEmail)}
placeholder="กดส่ง OTP ก่อน" disabled={busy || !pendingEmail} />
</Field>}
{captcha && !pendingEmail && <div ref={container} />}
{(!register || pendingEmail) && <Button type="submit" disabled={busy || (!register && captcha && !token)}>
{busy
? "กำลังดำเนินการ..."
: register
? "สมัครสมาชิก"
? "ยืนยันอีเมล"
: "เข้าสู่ระบบ"}
</Button>
</Button>}
</FieldGroup>
</form>
<Button
variant="link"
nativeButton={false}
render={
<Link
href={`${register ? "/login" : "/register"}${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`}
/>
}
>
{register ? "มีบัญชีแล้ว? เข้าสู่ระบบ" : "ยังไม่มีบัญชี? สมัครสมาชิก"}
</Button>
{register ? <Button variant="link" nativeButton={false}
render={<Link href={`/login${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
มีบัญชีแล้ว? เข้าสู่ระบบ
</Button> : <div className="flex items-center justify-between gap-2">
<Button variant="link" size="sm" className="px-0" nativeButton={false}
render={<Link href={`/register${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
สมัครสมาชิก
</Button>
<Button variant="link" size="sm" className="px-0" nativeButton={false}
render={<Link href="/forgot-password" />}>
ลืมรหัสผ่าน?
</Button>
</div>}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
+1 -1
View File
@@ -17,7 +17,7 @@ export function AccountMenu({ admin = false }: { admin?: boolean }) {
async function signOut() {
await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut();
router.push(admin ? "/admin/login" : "/commission");
router.push(admin ? "/login?next=%2Fadmin" : "/commission");
router.refresh();
}
+11 -3
View File
@@ -1,15 +1,23 @@
import { redirect } from "next/navigation";
import { SiteHeader } from "@/components/public/site-header";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { AccountForm } from "./account-form";
export async function AccountPage({ mode, next }: { mode: "login" | "register"; next: unknown }) {
export async function AccountPage({ mode, next, verified, verificationError }: {
mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean;
}) {
const nextPath = safeAuthReturnPath(next);
if (await getCustomerSession()) redirect(nextPath);
const session = await getCustomerSession();
if (session) {
if (nextPath.startsWith("/admin") && !isAuthorizedAdmin(session.user)) redirect("/");
redirect(nextPath);
}
const siteKey = process.env.TURNSTILE_SITE_KEY ?? "";
if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required");
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath} />
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath}
verified={verified} verificationError={verificationError} />
</main></div>;
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { useState, type FormEvent } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/client";
type Mode = "forgot" | "reset";
export function EmailActionForm({ mode, token = "", invalidToken = false }: {
mode: Mode; token?: string; invalidToken?: boolean;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [sent, setSent] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
const form = new FormData(event.currentTarget);
const address = String(form.get("email") ?? "").trim();
setBusy(true);
setError("");
try {
if (mode === "forgot") {
const result = await authClient.requestPasswordReset({ email: address, redirectTo: "/reset-password" });
if (result.error) throw new Error("ส่งลิงก์รีเซ็ตไม่สำเร็จ กรุณาลองอีกครั้ง");
} else {
const password = String(form.get("password") ?? "");
if (password !== String(form.get("confirmPassword") ?? "")) {
setError("รหัสผ่านใหม่ไม่ตรงกัน");
return;
}
const result = await authClient.resetPassword({ newPassword: password, token });
if (result.error) throw new Error("รีเซ็ตรหัสผ่านไม่สำเร็จ ลิงก์อาจหมดอายุแล้ว");
}
setSent(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
} finally {
setBusy(false);
}
}
const title = mode === "forgot" ? "ลืมรหัสผ่าน" : "ตั้งรหัสผ่านใหม่";
return <Card className="w-full max-w-sm">
<CardHeader><CardTitle>{title}</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
{sent && <Alert><AlertDescription>{mode === "reset"
? "ตั้งรหัสผ่านใหม่แล้ว กรุณาเข้าสู่ระบบ"
: "หากอีเมลนี้มีบัญชีอยู่ โปรดตรวจสอบกล่องจดหมายและโฟลเดอร์สแปม"}</AlertDescription></Alert>}
{invalidToken && <Alert variant="destructive"><AlertDescription>
ลิงก์รีเซ็ตไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่
</AlertDescription></Alert>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{(mode !== "reset" || (!sent && token)) && <form onSubmit={submit}>
<FieldGroup>
{mode !== "reset" ? <Field>
<FieldLabel htmlFor="auth-email">อีเมล</FieldLabel>
<Input id="auth-email" name="email" type="email" autoComplete="email" required disabled={busy} />
</Field> : <>
<Field><FieldLabel htmlFor="new-password">รหัสผ่านใหม่</FieldLabel>
<Input id="new-password" name="password" type="password" minLength={6} maxLength={128}
autoComplete="new-password" required disabled={busy} /></Field>
<Field><FieldLabel htmlFor="confirm-password">ยืนยันรหัสผ่านใหม่</FieldLabel>
<Input id="confirm-password" name="confirmPassword" type="password" minLength={6} maxLength={128}
autoComplete="new-password" required disabled={busy} /></Field>
</>}
<Button type="submit" disabled={busy}>{busy ? "กำลังดำเนินการ..." :
mode === "forgot" ? "ส่งลิงก์รีเซ็ต" : "ตั้งรหัสผ่าน"}</Button>
</FieldGroup>
</form>}
{mode === "reset" && !token && <Button variant="link" nativeButton={false} render={<Link href="/forgot-password" />}>
ขอลิงก์รีเซ็ตใหม่
</Button>}
<Button variant="link" nativeButton={false} render={<Link href="/login" />}>กลับไปเข้าสู่ระบบ</Button>
</CardContent>
</Card>;
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useState, type FormEvent } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/client";
export function EmailSettings({ email, verified, callbackCompleted, callbackError }: {
email: string; verified: boolean; callbackCompleted: boolean; callbackError: boolean;
}) {
const [newEmail, setNewEmail] = useState("");
const [busy, setBusy] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
async function requestVerification() {
if (sending) return;
setSending(true);
setError("");
setMessage("");
try {
const result = await authClient.sendVerificationEmail({ email, callbackURL: "/profile?emailAction=1" });
if (result.error) throw new Error("ส่งลิงก์ยืนยันไม่สำเร็จ กรุณาลองอีกครั้ง");
setMessage("ส่งลิงก์ยืนยันแล้ว กรุณาตรวจสอบอีเมลของคุณ");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ส่งลิงก์ยืนยันไม่สำเร็จ");
} finally { setSending(false); }
}
async function changeEmail(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
setMessage("");
try {
const result = await authClient.changeEmail({ newEmail: newEmail.trim(), callbackURL: "/profile?emailAction=1" });
if (result.error?.code === "SESSION_NOT_FRESH")
throw new Error("กรุณาออกจากระบบและเข้าสู่ระบบอีกครั้งก่อนเปลี่ยนอีเมล");
if (result.error) throw new Error("เปลี่ยนอีเมลไม่สำเร็จ กรุณาลองอีกครั้ง");
setNewEmail("");
setMessage(verified
? "ตรวจสอบอีเมลปัจจุบันเพื่ออนุมัติ แล้วตรวจสอบอีเมลใหม่เพื่อยืนยัน"
: "ตรวจสอบอีเมลใหม่เพื่อยืนยันการเปลี่ยนแปลง");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "เปลี่ยนอีเมลไม่สำเร็จ");
} finally { setBusy(false); }
}
return <Card>
<CardHeader><CardTitle>อีเมล</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm">{email} · {verified ? "ยืนยันแล้ว" : "ยังไม่ยืนยัน"}</p>
{callbackCompleted && <Alert><AlertDescription>ยืนยันขั้นตอนแล้ว หากกำลังเปลี่ยนอีเมล โปรดตรวจสอบกล่องจดหมายสำหรับขั้นตอนถัดไป</AlertDescription></Alert>}
{callbackError && <Alert variant="destructive"><AlertDescription>ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่</AlertDescription></Alert>}
{message && <Alert><AlertDescription>{message}</AlertDescription></Alert>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<form id="change-email-form" onSubmit={changeEmail}>
<FieldGroup><Field>
<FieldLabel htmlFor="new-email">อีเมลใหม่</FieldLabel>
<Input id="new-email" type="email" value={newEmail} onChange={(event) => setNewEmail(event.target.value)}
autoComplete="email" required disabled={busy || sending} />
</Field></FieldGroup>
</form>
</CardContent>
<CardFooter className="flex-wrap gap-2">
<Button type="submit" form="change-email-form" disabled={busy || sending || !newEmail.trim() || newEmail.trim().toLowerCase() === email.toLowerCase()}>
{busy ? "กำลังดำเนินการ..." : "เปลี่ยนอีเมล"}
</Button>
{!verified && <Button variant="outline" onClick={requestVerification} disabled={busy || sending}>
{sending ? "กำลังส่ง..." : "ขอลิงก์ยืนยันอีเมล"}
</Button>}
</CardFooter>
</Card>;
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/client";
export function PasswordForm() {
const formRef = useRef<HTMLFormElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
async function changePassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
const form = new FormData(event.currentTarget);
const currentPassword = String(form.get("currentPassword") ?? "");
const newPassword = String(form.get("newPassword") ?? "");
const confirmPassword = String(form.get("confirmPassword") ?? "");
setError("");
setSuccess(false);
if (newPassword !== confirmPassword) {
setError("รหัสผ่านใหม่ไม่ตรงกัน");
return;
}
setBusy(true);
try {
const result = await authClient.changePassword({ currentPassword, newPassword });
if (result.error) {
setError(result.error.code === "INVALID_PASSWORD"
? "รหัสผ่านปัจจุบันไม่ถูกต้อง"
: "เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง");
return;
}
formRef.current?.reset();
setSuccess(true);
} catch {
setError("เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง");
} finally {
setBusy(false);
}
}
return <Card>
<CardHeader>
<CardTitle>เปลี่ยนรหัสผ่าน</CardTitle>
<CardDescription>กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่</CardDescription>
</CardHeader>
<CardContent>
<form id="password-form" ref={formRef} onSubmit={changePassword}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="current-password">รหัสผ่านปัจจุบัน</FieldLabel>
<Input id="current-password" name="currentPassword" type="password"
autoComplete="current-password" required disabled={busy} />
</Field>
<Field>
<FieldLabel htmlFor="new-password">รหัสผ่านใหม่</FieldLabel>
<Input id="new-password" name="newPassword" type="password"
autoComplete="new-password" minLength={6} maxLength={128} required disabled={busy} />
</Field>
<Field>
<FieldLabel htmlFor="confirm-password">ยืนยันรหัสผ่านใหม่</FieldLabel>
<Input id="confirm-password" name="confirmPassword" type="password"
autoComplete="new-password" minLength={6} maxLength={128} required disabled={busy} />
</Field>
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{success && <Alert><AlertDescription>เปลี่ยนรหัสผ่านเรียบร้อยแล้ว</AlertDescription></Alert>}
</FieldGroup>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="password-form" disabled={busy}>
{busy ? "กำลังเปลี่ยนรหัสผ่าน..." : "เปลี่ยนรหัสผ่าน"}
</Button>
</CardFooter>
</Card>;
}
+7 -28
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ProfileImageInput } from "./profile-image-input";
export function ProfileForm({
name: initialName,
@@ -40,20 +41,6 @@ export function ProfileForm({
[preview],
);
function chooseFile(next: File | null) {
setError("");
if (
next &&
(next.size > 6 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(next.type))
) {
setError("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
return;
}
setFile(next);
setPreview(next ? URL.createObjectURL(next) : null);
}
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
@@ -102,22 +89,14 @@ export function ProfileForm({
{name.trim().charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<Input
<ProfileImageInput
id="profile-image"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
const selected = event.target.files?.[0] ?? null;
chooseFile(selected);
if (
selected &&
(selected.size > 6 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(
selected.type,
))
)
event.target.value = "";
onChange={(cropped) => {
setFile(cropped);
setPreview(URL.createObjectURL(cropped));
setError("");
}}
onError={setError}
disabled={busy}
/>
</div>
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Cropper, CropperCropArea, CropperDescription, CropperImage } from "@/components/ui/cropper";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
type CropArea = { x: number; y: number; width: number; height: number };
export function ProfileImageInput({ id, disabled, onChange, onError }: {
id: string;
disabled?: boolean;
onChange: (file: File) => void;
onError: (message: string) => void;
}) {
const [pending, setPending] = useState<{ url: string } | null>(null);
const [cropArea, setCropArea] = useState<CropArea | null>(null);
const [cropping, setCropping] = useState(false);
useEffect(() => () => {
if (pending) URL.revokeObjectURL(pending.url);
}, [pending]);
function select(file: File | null) {
if (!file) return;
if (file.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
onError("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
return;
}
onError("");
setCropArea(null);
setPending({ url: URL.createObjectURL(file) });
}
async function applyCrop() {
if (!pending || !cropArea || cropping) return;
setCropping(true);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const candidate = new Image();
candidate.onload = () => resolve(candidate);
candidate.onerror = reject;
candidate.src = pending.url;
});
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas unavailable");
context.imageSmoothingQuality = "high";
context.drawImage(image, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, 256, 256);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new Error("Image encoding failed");
onChange(new File([blob], "profile.png", { type: "image/png" }));
setPending(null);
} catch {
onError("ไม่สามารถครอปไฟล์ภาพนี้ได้");
} finally {
setCropping(false);
}
}
return <>
<Input id={id} type="file" accept="image/png,image/jpeg,image/webp" disabled={disabled}
onChange={(event) => {
select(event.target.files?.[0] ?? null);
event.target.value = "";
}} />
<Dialog open={!!pending} onOpenChange={(open) => { if (!open && !cropping) setPending(null); }}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>ครอปรูปโปรไฟล์ 1:1</DialogTitle>
<DialogDescription>ลากภาพเพื่อเลือกส่วนที่ต้องการใช้เป็นรูปโปรไฟล์</DialogDescription>
</DialogHeader>
{pending && <Cropper key={pending.url} image={pending.url} aspectRatio={1}
onCropChange={setCropArea} className="h-80 rounded-lg bg-black sm:h-96">
<CropperDescription>ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม</CropperDescription>
<CropperImage />
<CropperCropArea />
</Cropper>}
<DialogFooter>
<Button type="button" variant="outline" disabled={cropping} onClick={() => setPending(null)}>ยกเลิก</Button>
<Button type="button" disabled={!cropArea || cropping} onClick={() => void applyCrop()}>
{cropping ? "กำลังครอป..." : "ใช้ภาพนี้"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>;
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRef, useState, type FormEvent } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/client";
export function RegistrationOtpForm({ email, nextPath, setup }: {
email: string; nextPath: string; setup: boolean;
}) {
const router = useRouter();
const emailRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [sent, setSent] = useState(setup);
async function verify(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
const form = new FormData(event.currentTarget);
const address = String(form.get("email") ?? "").trim();
const otp = String(form.get("otp") ?? "").trim();
setBusy(true);
setError("");
try {
const result = await authClient.emailOtp.verifyEmail({ email: address, otp });
if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง");
const returnPath = setup ? `/profile?setup=1&next=${encodeURIComponent(nextPath)}` : nextPath;
router.push(`/login?verified=1&next=${encodeURIComponent(returnPath)}`);
router.refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ยืนยันอีเมลไม่สำเร็จ");
} finally { setBusy(false); }
}
async function resend() {
if (sending || busy) return;
const address = emailRef.current?.value.trim() ?? "";
if (!address) return;
setSending(true);
setError("");
try {
const result = await authClient.emailOtp.sendVerificationOtp({ email: address, type: "email-verification" });
if (result.error) throw new Error("ส่งรหัสใหม่ไม่สำเร็จ กรุณาลองอีกครั้ง");
setSent(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "ส่งรหัสใหม่ไม่สำเร็จ");
} finally { setSending(false); }
}
return <Card className="w-full max-w-sm">
<CardHeader><CardTitle>ยืนยันอีเมล</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
{sent && <Alert><AlertDescription>กรอกรหัส 6 หลักที่ส่งไปยังอีเมลของคุณ รหัสหมดอายุใน 5 นาที</AlertDescription></Alert>}
{!sent && <p className="text-sm text-muted-foreground">กรอกรหัสจากอีเมล หรือกดส่งรหัสใหม่หากยังไม่มีรหัส</p>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<form onSubmit={verify}>
<FieldGroup>
<Field><FieldLabel htmlFor="otp-email">อีเมล</FieldLabel>
<Input ref={emailRef} id="otp-email" name="email" type="email" defaultValue={email} autoComplete="email" required disabled={busy || sending} />
</Field>
<Field><FieldLabel htmlFor="otp-code">รหัส OTP</FieldLabel>
<Input id="otp-code" name="otp" inputMode="numeric" autoComplete="one-time-code"
pattern="[0-9]{6}" minLength={6} maxLength={6} required disabled={busy || sending} />
</Field>
<Button type="submit" disabled={busy || sending}>{busy ? "กำลังยืนยัน..." : "ยืนยันอีเมล"}</Button>
</FieldGroup>
</form>
<Button variant="link" onClick={resend} disabled={busy || sending}>
{sending ? "กำลังส่ง..." : "ส่งรหัสใหม่"}
</Button>
<Button variant="link" nativeButton={false} render={<Link href="/login" />}>กลับไปเข้าสู่ระบบ</Button>
</CardContent>
</Card>;
}