feat: otp flow connected to backend

This commit is contained in:
Afra ‌ 2026-06-21 16:26:49 +03:30
parent 0cc04919be
commit 67fe31eec2
12 changed files with 326 additions and 5 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
VITE_AUTH_CLIENT_ID=
VITE_AUTH_CLIENT_SECRET=

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ dist
.pnpm-store .pnpm-store
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View file

@ -10,6 +10,7 @@ import AddEvent from './pages/AddEvent'
import AddEventSuccess from './pages/AddEventSuccess' import AddEventSuccess from './pages/AddEventSuccess'
import EventReservations from './pages/EventReservations' import EventReservations from './pages/EventReservations'
import Login from './pages/Login' import Login from './pages/Login'
import LoginOtp from './pages/LoginOtp'
function ScrollToTop() { function ScrollToTop() {
const { pathname } = useLocation() const { pathname } = useLocation()
@ -36,6 +37,7 @@ export default function App() {
<Route path="/owner/events/success" element={<AddEventSuccess />} /> <Route path="/owner/events/success" element={<AddEventSuccess />} />
<Route path="/owner/events/:id/reservations" element={<EventReservations />} /> <Route path="/owner/events/:id/reservations" element={<EventReservations />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/login/otp" element={<LoginOtp />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
) )

5
src/assets/edit-2.svg Normal file
View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2594 3.59924L5.04936 12.2892C4.73936 12.6192 4.43936 13.2692 4.37936 13.7192L4.00936 16.9592C3.87936 18.1292 4.71936 18.9292 5.87936 18.7292L9.09936 18.1792C9.54936 18.0992 10.1794 17.7692 10.4894 17.4292L18.6994 8.73924C20.1194 7.23924 20.7594 5.52924 18.5494 3.43924C16.3494 1.36924 14.6794 2.09924 13.2594 3.59924Z" stroke="#223D5D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.8906 5.05078C12.3206 7.81078 14.5606 9.92078 17.3406 10.2008" stroke="#223D5D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 22H21" stroke="#223D5D" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 853 B

View file

@ -1,4 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { requestOtp } from "../services/authApi";
import { toE164 } from "../utils/phone";
function CheckIcon() { function CheckIcon() {
return ( return (
@ -17,10 +20,29 @@ function CheckIcon() {
} }
export default function Login() { export default function Login() {
const [phone, setPhone] = useState(""); const navigate = useNavigate();
const [acceptedTerms, setAcceptedTerms] = useState(false); 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 ( return (
<div className="flex min-h-screen flex-col justify-center px-6"> <div className="flex min-h-screen flex-col justify-center px-6">
@ -35,17 +57,23 @@ export default function Login() {
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => {
setPhone(e.target.value);
setError("");
}}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" 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]" 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 && <p className="mt-2 text-right text-sm text-red-500">{error}</p>}
<button <button
type="button" type="button"
disabled={!canContinue} disabled={!canContinue}
onClick={handleContinue}
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50" className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
> >
ادامه {isSubmitting ? "در حال ارسال..." : "ادامه"}
</button> </button>
<div className="mt-4 flex items-center justify-center gap-2"> <div className="mt-4 flex items-center justify-center gap-2">

173
src/pages/LoginOtp.tsx Normal file
View file

@ -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 (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path
strokeMiterlimit={10}
d="M13.2594 3.59924L5.04936 12.2892C4.73936 12.6192 4.43936 13.2692 4.37936 13.7192L4.00936 16.9592C3.87936 18.1292 4.71936 18.9292 5.87936 18.7292L9.09936 18.1792C9.54936 18.0992 10.1794 17.7692 10.4894 17.4292L18.6994 8.73924C20.1194 7.23924 20.7594 5.52924 18.5494 3.43924C16.3494 1.36924 14.6794 2.09924 13.2594 3.59924Z"
/>
<path strokeMiterlimit={10} d="M11.8906 5.05078C12.3206 7.81078 14.5606 9.92078 17.3406 10.2008" />
<path strokeMiterlimit={10} d="M3 22H21" />
</svg>
)
}
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<string[]>(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<HTMLInputElement>) => {
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 (
<div className="flex min-h-screen flex-col justify-center px-6">
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
<div className="mt-2 flex items-center justify-start gap-2">
<span className="text-[#223D5D]">{phone}</span>
<button type="button" onClick={() => navigate('/login', { state: { phone } })} aria-label="ویرایش شماره تماس">
<EditIcon className="h-5 w-5 text-[#223D5D]" />
</button>
</div>
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
{otp.map((digit, index) => (
<input
key={index}
ref={(el) => {
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"
/>
))}
</div>
<div className="mt-4 text-center">
{secondsLeft > 0 ? (
<p className="text-gray-500">
ارسال مجدد کد: {formatCountdown(secondsLeft)}
</p>
) : (
<button type="button" onClick={handleResend} disabled={isResending} className="text-[#376195] underline disabled:opacity-50">
{isResending ? 'در حال ارسال...' : 'ارسال مجدد کد'}
</button>
)}
</div>
{error && <p className="mt-2 text-center text-sm text-red-500">{error}</p>}
<button
type="button"
disabled={!canSubmit}
onClick={handleSubmit}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? 'در حال ورود...' : 'ورود'}
</button>
</div>
)
}

View file

@ -1,10 +1,19 @@
import axios from 'axios' import axios from 'axios'
import { getAccessToken } from '../utils/authToken'
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/', baseURL: import.meta.env.VITE_API_BASE_URL || '/',
headers: { 'Content-Type': 'application/json' } 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 default api
export const exampleFetch = async (path: string) => { export const exampleFetch = async (path: string) => {

68
src/services/authApi.ts Normal file
View file

@ -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<RequestOtpResponse> {
const { data } = await authApi.post<RequestOtpResponse>('/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<TokenResponse> {
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<TokenResponse>('/oauth2/token/', body, {
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
})
return data
}

11
src/utils/authToken.ts Normal file
View file

@ -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)
}

View file

@ -1,3 +1,8 @@
export function formatPersianNumber(n: number): string { export function formatPersianNumber(n: number): string {
return n.toLocaleString('fa-IR') return n.toLocaleString('fa-IR')
} }
export function toEnglishDigits(value: string): string {
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
}

7
src/utils/phone.ts Normal file
View file

@ -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}`
}

9
src/utils/role.ts Normal file
View file

@ -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'
}