From 67fe31eec2cd8435c171ee7bc09e531cb840c912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sun, 21 Jun 2026 16:26:49 +0330 Subject: [PATCH] feat: otp flow connected to backend --- .env.example | 3 + .gitignore | 1 + src/App.tsx | 2 + src/assets/edit-2.svg | 5 ++ src/pages/Login.tsx | 38 +++++++-- src/pages/LoginOtp.tsx | 173 ++++++++++++++++++++++++++++++++++++++ src/services/api.ts | 9 ++ src/services/authApi.ts | 68 +++++++++++++++ src/utils/authToken.ts | 11 +++ src/utils/formatNumber.ts | 5 ++ src/utils/phone.ts | 7 ++ src/utils/role.ts | 9 ++ 12 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 .env.example create mode 100644 src/assets/edit-2.svg create mode 100644 src/pages/LoginOtp.tsx create mode 100644 src/services/authApi.ts create mode 100644 src/utils/authToken.ts create mode 100644 src/utils/phone.ts create mode 100644 src/utils/role.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c82173b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir +VITE_AUTH_CLIENT_ID= +VITE_AUTH_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index b7b9ab0..e0e39f0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist .pnpm-store dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/src/App.tsx b/src/App.tsx index 9cea3b5..10f9c63 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import AddEvent from './pages/AddEvent' import AddEventSuccess from './pages/AddEventSuccess' import EventReservations from './pages/EventReservations' import Login from './pages/Login' +import LoginOtp from './pages/LoginOtp' function ScrollToTop() { const { pathname } = useLocation() @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/src/assets/edit-2.svg b/src/assets/edit-2.svg new file mode 100644 index 0000000..5ee713b --- /dev/null +++ b/src/assets/edit-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d280ef9..44e0b2b 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,4 +1,7 @@ import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { requestOtp } from "../services/authApi"; +import { toE164 } from "../utils/phone"; function CheckIcon() { return ( @@ -17,10 +20,29 @@ function CheckIcon() { } export default function Login() { - const [phone, setPhone] = useState(""); - const [acceptedTerms, setAcceptedTerms] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + const initialPhone = (location.state as { phone?: string } | null)?.phone ?? ""; - const canContinue = phone.trim().length > 0 && acceptedTerms; + const [phone, setPhone] = useState(initialPhone); + const [acceptedTerms, setAcceptedTerms] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + + const canContinue = phone.trim().length > 0 && acceptedTerms && !isSubmitting; + + const handleContinue = async () => { + setError(""); + setIsSubmitting(true); + try { + const { ttl } = await requestOtp(toE164(phone)); + navigate("/login/otp", { state: { phone, ttl } }); + } catch { + setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید."); + } finally { + setIsSubmitting(false); + } + }; return (
@@ -35,17 +57,23 @@ export default function Login() { type="text" inputMode="numeric" value={phone} - onChange={(e) => setPhone(e.target.value)} + onChange={(e) => { + setPhone(e.target.value); + setError(""); + }} placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" className="mt-6 h-14 w-full rounded-lg border border-gray-200 bg-[#F8F8F8] px-4 text-right placeholder:font-light placeholder:text-[#999999]" /> + {error &&

{error}

} +
diff --git a/src/pages/LoginOtp.tsx b/src/pages/LoginOtp.tsx new file mode 100644 index 0000000..ac1101b --- /dev/null +++ b/src/pages/LoginOtp.tsx @@ -0,0 +1,173 @@ +import { useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { requestOtp, verifyOtp } from '../services/authApi' +import { storeAuthTokens } from '../utils/authToken' +import { toEnglishDigits } from '../utils/formatNumber' +import { toE164 } from '../utils/phone' +import { getUserRole } from '../utils/role' + +const OTP_LENGTH = 5 +const DEFAULT_RESEND_SECONDS = 120 + +function EditIcon({ className = '' }: { className?: string }) { + return ( + + + + + + ) +} + +function toPersianDigits(value: string) { + const persianDigits = '۰۱۲۳۴۵۶۷۸۹' + return value.replace(/[0-9]/g, (digit) => persianDigits[Number(digit)]) +} + +function formatCountdown(seconds: number) { + const mm = String(Math.floor(seconds / 60)).padStart(2, '0') + const ss = String(seconds % 60).padStart(2, '0') + return toPersianDigits(`${mm}:${ss}`) +} + +export default function LoginOtp() { + const navigate = useNavigate() + const location = useLocation() + const state = location.state as { phone?: string; ttl?: number } | null + const phone = state?.phone ?? '' + + const [otp, setOtp] = useState(Array(OTP_LENGTH).fill('')) + const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS)) + const [resendCycle, setResendCycle] = useState(0) + const [error, setError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [isResending, setIsResending] = useState(false) + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + useEffect(() => { + if (!phone) { + navigate('/login', { replace: true }) + } + }, [phone, navigate]) + + useEffect(() => { + const timer = setInterval(() => { + setSecondsLeft((s) => { + if (s <= 1) { + clearInterval(timer) + return 0 + } + return s - 1 + }) + }, 1000) + return () => clearInterval(timer) + }, [resendCycle]) + + const handleOtpChange = (index: number, rawValue: string) => { + const digit = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(-1) + setOtp((prev) => { + const next = [...prev] + next[index] = digit + return next + }) + setError('') + if (digit && index < OTP_LENGTH - 1) { + inputRefs.current[index + 1]?.focus() + } + } + + const handleOtpKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + } + + const handleResend = async () => { + setError('') + setIsResending(true) + try { + const { ttl } = await requestOtp(toE164(phone)) + setOtp(Array(OTP_LENGTH).fill('')) + inputRefs.current[0]?.focus() + setSecondsLeft(Math.round(ttl)) + setResendCycle((c) => c + 1) + } catch { + setError('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.') + } finally { + setIsResending(false) + } + } + + const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting + + const handleSubmit = async () => { + setError('') + setIsSubmitting(true) + try { + const tokens = await verifyOtp(toE164(phone), otp.join('')) + storeAuthTokens(tokens.access_token, tokens.refresh_token) + const role = getUserRole() + navigate(role === 'cafe_owner' ? '/owner/panel' : '/') + } catch { + setError('کد وارد شده صحیح نیست') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

کد ارسال شده را وارد کنید:

+
+ {phone} + +
+ +
+ {otp.map((digit, index) => ( + { + inputRefs.current[index] = el + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleOtpChange(index, e.target.value)} + onKeyDown={(e) => handleOtpKeyDown(index, e)} + className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none" + /> + ))} +
+ +
+ {secondsLeft > 0 ? ( +

+ ارسال مجدد کد: {formatCountdown(secondsLeft)} +

+ ) : ( + + )} +
+ + {error &&

{error}

} + + +
+ ) +} diff --git a/src/services/api.ts b/src/services/api.ts index 5625ca5..7c4aa5f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,10 +1,19 @@ import axios from 'axios' +import { getAccessToken } from '../utils/authToken' const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/', headers: { 'Content-Type': 'application/json' } }) +api.interceptors.request.use((config) => { + const token = getAccessToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + export default api export const exampleFetch = async (path: string) => { diff --git a/src/services/authApi.ts b/src/services/authApi.ts new file mode 100644 index 0000000..a37790d --- /dev/null +++ b/src/services/authApi.ts @@ -0,0 +1,68 @@ +import axios from 'axios' + +const authApi = axios.create({ + baseURL: import.meta.env.VITE_AUTH_BASE_URL, + headers: { Accept: 'application/json', 'Accept-Language': 'fa' }, +}) + +const SCOPE = [ + 'accounts.account:change_password', + 'accounts.account:update', + 'accounts.account:retrieve', + 'wallet.wallet:get_balance', + 'billboard_merchant.billboard:retrieve', + 'billboard_merchant.billboard:update', + 'billboard_merchant.billboard:create', + 'wallet.user:transaction_list', + 'data_crud.data:retrieve', + 'data_crud.data:update', + 'data_crud.data:delete', + 'notifications.push:get_client_token', + 'crm.message:create', + 'crm.message:retrieve', + 'accounts.profile:list', + 'billboard_merchant.billboard:delete', +].join(' ') + +export interface RequestOtpResponse { + phone_number: string + otp_expire: string + ttl: number +} + +export async function requestOtp(phoneNumber: string): Promise { + const { data } = await authApi.post('/users/api/request_otp/', { + phone_number: phoneNumber, + }) + return data +} + +export interface TokenResponse { + access_token: string + refresh_token: string + expires_in: number + token_type: string + scope: string +} + +export async function verifyOtp(phoneNumber: string, otpCode: string): Promise { + const clientId = import.meta.env.VITE_AUTH_CLIENT_ID + const clientSecret = import.meta.env.VITE_AUTH_CLIENT_SECRET + const credentials = btoa(`${clientId}:${clientSecret}`) + + const body = new URLSearchParams({ + grant_type: 'password', + username: phoneNumber, + password: otpCode, + scope: SCOPE, + auth_fields: 'phone_number:otp', + }) + + const { data } = await authApi.post('/oauth2/token/', body, { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + return data +} diff --git a/src/utils/authToken.ts b/src/utils/authToken.ts new file mode 100644 index 0000000..d0929c1 --- /dev/null +++ b/src/utils/authToken.ts @@ -0,0 +1,11 @@ +const ACCESS_TOKEN_KEY = 'access_token' +const REFRESH_TOKEN_KEY = 'refresh_token' + +export function storeAuthTokens(accessToken: string, refreshToken: string) { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken) + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken) +} + +export function getAccessToken(): string | null { + return localStorage.getItem(ACCESS_TOKEN_KEY) +} diff --git a/src/utils/formatNumber.ts b/src/utils/formatNumber.ts index cdb04c2..db6877d 100644 --- a/src/utils/formatNumber.ts +++ b/src/utils/formatNumber.ts @@ -1,3 +1,8 @@ export function formatPersianNumber(n: number): string { return n.toLocaleString('fa-IR') } + +export function toEnglishDigits(value: string): string { + const persianDigits = '۰۱۲۳۴۵۶۷۸۹' + return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit))) +} diff --git a/src/utils/phone.ts b/src/utils/phone.ts new file mode 100644 index 0000000..7bd8a48 --- /dev/null +++ b/src/utils/phone.ts @@ -0,0 +1,7 @@ +import { toEnglishDigits } from './formatNumber' + +export function toE164(localPhone: string): string { + const digitsOnly = toEnglishDigits(localPhone).replace(/\D/g, '') + const withoutLeadingZero = digitsOnly.replace(/^0+/, '') + return `+98${withoutLeadingZero}` +} diff --git a/src/utils/role.ts b/src/utils/role.ts new file mode 100644 index 0000000..4080b40 --- /dev/null +++ b/src/utils/role.ts @@ -0,0 +1,9 @@ +export type UserRole = 'customer' | 'cafe_owner' + +const ROLE_STORAGE_KEY = 'role' + +// dev-only helper until real auth/role assignment exists — set via +// localStorage.setItem('role', 'cafe_owner') in devtools to test that flow +export function getUserRole(): UserRole { + return localStorage.getItem(ROLE_STORAGE_KEY) === 'cafe_owner' ? 'cafe_owner' : 'customer' +}