feat : kavenegar otp added

This commit is contained in:
soroush.asadi
2026-05-29 10:18:47 +03:30
parent 27e61d257e
commit 16cff8730b
22 changed files with 502 additions and 34 deletions
+12 -1
View File
@@ -41,7 +41,18 @@
"rateLimited": "Too many code requests. Wait up to one hour or contact support.",
"notFound": "No account found for this mobile number.",
"smsFailed": "Could not send SMS. Please try again.",
"invalidOtp": "Invalid or expired verification code."
"invalidOtp": "Invalid or expired verification code.",
"register": "Create account",
"registerSubtitle": "Register your cafe on Meezi",
"cafeName": "Cafe or restaurant name",
"cafeNamePlaceholder": "e.g. Roya Cafe",
"createAccount": "Create account",
"alreadyHaveAccount": "Already have an account?",
"loginLink": "Sign in",
"noAccount": "Don't have an account?",
"registerLink": "Register",
"alreadyRegistered": "This phone is already registered. Please sign in.",
"registrationExpired": "Registration session expired. Please try again."
},
"nav": {
"aria": "Main navigation",
+12 -1
View File
@@ -41,7 +41,18 @@
"rateLimited": "تعداد درخواست کد بیش از حد است. حداکثر یک ساعت صبر کنید یا با پشتیبانی تماس بگیرید.",
"notFound": "حسابی با این شماره موبایل یافت نشد.",
"smsFailed": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.",
"invalidOtp": "کد تأیید نادرست یا منقضی شده است."
"invalidOtp": "کد تأیید نادرست یا منقضی شده است.",
"register": "ثبت‌نام",
"registerSubtitle": "کافه خود را در میزی ثبت کنید",
"cafeName": "نام کافه یا رستوران",
"cafeNamePlaceholder": "مثال: کافه رویا",
"createAccount": "ایجاد حساب",
"alreadyHaveAccount": "حساب دارید؟",
"loginLink": "ورود",
"noAccount": "حساب ندارید؟",
"registerLink": "ثبت‌نام",
"alreadyRegistered": "این شماره قبلاً ثبت‌نام کرده است. لطفاً وارد شوید.",
"registrationExpired": "زمان ثبت‌نام منقضی شد. دوباره تلاش کنید."
},
"nav": {
"aria": "منوی اصلی",
+13 -3
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { useRouter, Link } from "@/i18n/routing";
import { apiPost, ApiClientError } from "@/lib/api/client";
import type { AuthTokenResponse } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
@@ -27,8 +27,6 @@ export default function LoginPage() {
switch (err.code) {
case "RATE_LIMITED":
return t("rateLimited");
case "NOT_FOUND":
return t("notFound");
case "SMS_FAILED":
return t("smsFailed");
case "INVALID_OTP":
@@ -47,6 +45,11 @@ export default function LoginPage() {
await apiPost("/api/auth/send-otp", { phone });
setStep("otp");
} catch (e) {
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
// No account → take them to register with phone pre-filled
router.push(`/register?phone=${encodeURIComponent(phone)}`);
return;
}
setError(authErrorMessage(e));
} finally {
setLoading(false);
@@ -137,6 +140,13 @@ export default function LoginPage() {
{error && (
<p className="text-center text-sm text-destructive">{error}</p>
)}
<p className="text-center text-sm text-muted-foreground">
{t("noAccount")}{" "}
<Link href="/register" className="font-medium text-primary hover:underline">
{t("registerLink")}
</Link>
</p>
</CardContent>
</Card>
</div>
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter, Link } from "@/i18n/routing";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { apiPost, ApiClientError } from "@/lib/api/client";
import type { AuthTokenResponse } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function RegisterForm() {
const t = useTranslations("auth");
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
const searchParams = useSearchParams();
const [phone, setPhone] = useState(searchParams.get("phone") ?? "");
const [cafeName, setCafeName] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"info" | "otp">("info");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorMessage = (err: unknown) => {
if (err instanceof ApiClientError) {
switch (err.code) {
case "RATE_LIMITED": return t("rateLimited");
case "ALREADY_REGISTERED": return t("alreadyRegistered");
case "SMS_FAILED": return t("smsFailed");
case "INVALID_OTP": return t("invalidOtp");
case "REGISTRATION_EXPIRED": return t("registrationExpired");
default: return err.message;
}
}
return err instanceof Error ? err.message : String(err);
};
const sendOtp = async () => {
setLoading(true);
setError(null);
try {
await apiPost("/api/auth/register", { phone, cafeName });
setStep("otp");
} catch (e) {
setError(errorMessage(e));
} finally {
setLoading(false);
}
};
const verifyOtp = async () => {
setLoading(true);
setError(null);
try {
const data = await apiPost<AuthTokenResponse>("/api/auth/verify-register", { phone, code });
setAuth(data);
router.push("/");
} catch (e) {
setError(errorMessage(e));
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-primary">{t("register")}</CardTitle>
<p className="text-center text-sm text-muted-foreground">{t("registerSubtitle")}</p>
</CardHeader>
<CardContent className="space-y-4">
{step === "info" ? (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void sendOtp();
}}
>
<LabeledField label={t("cafeName")} htmlFor="reg-cafe-name">
<Input
id="reg-cafe-name"
value={cafeName}
onChange={(e) => setCafeName(e.target.value)}
placeholder={t("cafeNamePlaceholder")}
autoComplete="organization"
required
/>
</LabeledField>
<LabeledField label={t("phone")} htmlFor="reg-phone">
<Input
id="reg-phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder={t("phonePlaceholder")}
dir="ltr"
className="text-end"
autoComplete="tel"
required
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "..." : t("sendOtp")}
</Button>
</form>
) : (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void verifyOtp();
}}
>
<LabeledField label={t("otp")} htmlFor="reg-otp">
<Input
id="reg-otp"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("otpPlaceholder")}
maxLength={6}
dir="ltr"
className="text-center tracking-widest"
autoComplete="one-time-code"
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "..." : t("createAccount")}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => { setStep("info"); setCode(""); }}
>
{t("resend")}
</Button>
</form>
)}
{error && (
<p className="text-center text-sm text-destructive">{error}</p>
)}
<p className="text-center text-sm text-muted-foreground">
{t("alreadyHaveAccount")}{" "}
<Link href="/login" className="font-medium text-primary hover:underline">
{t("loginLink")}
</Link>
</p>
</CardContent>
</Card>
</div>
);
}
export default function RegisterPage() {
return (
<Suspense>
<RegisterForm />
</Suspense>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// Middleware handles locale routing, but if it ever misses, redirect to /fa
export default function RootPage() {
redirect("/fa");
}
+9
View File
@@ -0,0 +1,9 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
// Match all pathnames except Next.js internals and static files
matcher: ["/((?!_next|_vercel|.*\\..*).*)"],
};