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