diff --git a/app.json b/app.json index d910bc9..d6f142a 100644 --- a/app.json +++ b/app.json @@ -34,7 +34,8 @@ "imageWidth": 76 } } - ] + ], + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/package-lock.json b/package-lock.json index abec3fa..9cf8530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", "@react-navigation/native": "^7.1.33", + "axios": "^1.16.1", "expo": "~55.0.24", "expo-constants": "~55.0.16", "expo-device": "~55.0.17", @@ -19,6 +20,7 @@ "expo-image": "~55.0.10", "expo-linking": "~55.0.15", "expo-router": "~55.0.14", + "expo-secure-store": "~55.0.14", "expo-splash-screen": "~55.0.21", "expo-status-bar": "~55.0.6", "expo-symbols": "~55.0.8", @@ -3098,6 +3100,45 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3466,6 +3507,18 @@ "node": ">= 0.8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3631,6 +3684,17 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -3823,6 +3887,14 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3858,6 +3930,19 @@ "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3889,6 +3974,14 @@ "stackframe": "^1.3.4" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -3897,6 +3990,31 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4229,6 +4347,14 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-55.0.14.tgz", + "integrity": "sha512-OKp9pDiTa4kgChop8+pTRJGBPhkJUcAxP5c6JbivNr4bmx3I+gKmAj1ov4KOXkY95TpWdHO+GQ4+0BgSY2P3JQ==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "55.0.9", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.9.tgz", @@ -4657,11 +4783,64 @@ "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fontfaceobserver": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -4712,6 +4891,29 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4728,6 +4930,18 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -4752,6 +4966,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4765,6 +4990,31 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -5649,6 +5899,14 @@ "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -6572,6 +6830,14 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", diff --git a/package.json b/package.json index fca9be7..b8b5431 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", "@react-navigation/native": "^7.1.33", + "axios": "^1.16.1", "expo": "~55.0.24", "expo-constants": "~55.0.16", "expo-device": "~55.0.17", @@ -22,6 +23,7 @@ "expo-image": "~55.0.10", "expo-linking": "~55.0.15", "expo-router": "~55.0.14", + "expo-secure-store": "~55.0.14", "expo-splash-screen": "~55.0.21", "expo-status-bar": "~55.0.6", "expo-symbols": "~55.0.8", @@ -31,11 +33,11 @@ "react-dom": "19.2.0", "react-native": "0.83.6", "react-native-gesture-handler": "~2.30.0", - "react-native-worklets": "0.7.4", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", - "react-native-web": "~0.21.0" + "react-native-web": "~0.21.0", + "react-native-worklets": "0.7.4" }, "devDependencies": { "@types/react": "~19.2.2", diff --git a/src/app/(auth)/OTPLogin.tsx b/src/app/(auth)/OTPLogin.tsx new file mode 100644 index 0000000..99c22a3 --- /dev/null +++ b/src/app/(auth)/OTPLogin.tsx @@ -0,0 +1,229 @@ +import { useAuth } from "@/constants/AuthContext"; +import { useTheme } from "@/hooks/use-theme"; +import { authStyles } from "@/styles/auth.styles"; +import { Feather } from "@expo/vector-icons"; +import { router, useLocalSearchParams } from "expo-router"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import apiClient from "../../services/api"; + +export default function OTPLogin() { + const { phoneNumber } = useLocalSearchParams<{ phoneNumber: string }>(); + const [otp, setOtp] = useState(""); + const [focusedField, setFocusedField] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [timeLeft, setTimeLeft] = useState(120); + const [canResend, setCanResend] = useState(false); + const { signIn } = useAuth(); + const theme = useTheme(); + + const scrollViewRef = useRef(null); + + useEffect(() => { + if (timeLeft <= 0) { + setCanResend(true); + return; + } + const timer = setInterval(() => { + setTimeLeft((prev) => prev - 1); + }, 1000); + return () => clearInterval(timer); + }, [timeLeft]); + + const handleBack = () => { + router.back(); + }; + + const handleVerifyOTP = async () => { + if (!otp || otp.length < 4) { + Alert.alert("خطا", "لطفا کد تایید را کامل وارد کنید"); + return; + } + + setIsLoading(true); + try { + const response = await apiClient.post("/api/auth/verify_login/", { + phone_number: phoneNumber, + code: otp, + }); + const { access } = response.data; + await signIn(access); + } catch (error: any) { + Alert.alert( + "خطا", + error.response?.data?.message || "کد تایید اشتباه است", + ); + } finally { + setIsLoading(false); + } + }; + + const handleResendCode = async () => { + if (!phoneNumber) return; + if (!canResend) { + Alert.alert("صبر کنید", `لطفاً ${timeLeft} ثانیه دیگر تلاش کنید`); + return; + } + + setIsLoading(true); + try { + await apiClient.post("/api/auth/login/", { phone_number: phoneNumber }); + Alert.alert("کد مجدداً ارسال شد"); + setTimeLeft(120); + setCanResend(false); + } catch (error: any) { + Alert.alert("خطا", "مشکل در ارسال مجدد کد"); + } finally { + setIsLoading(false); + } + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs < 10 ? "0" : ""}${secs}`; + }; + + const handleFocus = () => { + setTimeout(() => { + scrollViewRef.current?.scrollTo({ y: 120, animated: true }); + }, 100); + }; + + const styles = useMemo( + () => authStyles(theme, focusedField), + [theme, focusedField], + ); + + return ( + + + + + + + + + + + + + + + + تایید کد + + + + + کد تایید برای شماره {phoneNumber} ارسال شد + + + کد تایید + + + { + setFocusedField("otp"); + handleFocus(); + }} + onBlur={() => setFocusedField(null)} + keyboardType="number-pad" + maxLength={6} + autoFocus + textAlign="center" + /> + + + + {!isLoading && ( + + )} + + {isLoading ? "در حال تایید..." : "تایید کد"} + + + + + + + {!canResend + ? `ارسال مجدد کد (${formatTime(timeLeft)})` + : "ارسال مجدد کد"} + + + + + + + ); +} diff --git a/src/app/(auth)/OTPRegister.tsx b/src/app/(auth)/OTPRegister.tsx new file mode 100644 index 0000000..810fd43 --- /dev/null +++ b/src/app/(auth)/OTPRegister.tsx @@ -0,0 +1,229 @@ +import { useAuth } from "@/constants/AuthContext"; +import { useTheme } from "@/hooks/use-theme"; +import { authStyles } from "@/styles/auth.styles"; +import { Feather } from "@expo/vector-icons"; +import { router, useLocalSearchParams } from "expo-router"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import apiClient from "../../services/api"; + +export default function OTPRegister() { + const { phoneNumber } = useLocalSearchParams<{ phoneNumber: string }>(); + const [otp, setOtp] = useState(""); + const [focusedField, setFocusedField] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [timeLeft, setTimeLeft] = useState(120); + const [canResend, setCanResend] = useState(false); + const { signIn } = useAuth(); + const theme = useTheme(); + + const scrollViewRef = useRef(null); + + useEffect(() => { + if (timeLeft <= 0) { + setCanResend(true); + return; + } + const timer = setInterval(() => { + setTimeLeft((prev) => prev - 1); + }, 1000); + return () => clearInterval(timer); + }, [timeLeft]); + + const handleBack = () => { + router.back(); + }; + + const handleVerifyOTP = async () => { + if (!otp || otp.length < 4) { + Alert.alert("خطا", "لطفا کد تایید را کامل وارد کنید"); + return; + } + + setIsLoading(true); + try { + const response = await apiClient.post("/api/auth/verify_register/", { + phone_number: phoneNumber, + code: otp, + }); + const { access } = response.data; + await signIn(access); + } catch (error: any) { + Alert.alert( + "خطا", + error.response?.data?.message || "کد تایید اشتباه است", + ); + } finally { + setIsLoading(false); + } + }; + + const handleResendCode = async () => { + if (!phoneNumber) return; + if (!canResend) { + Alert.alert("صبر کنید", `لطفاً ${timeLeft} ثانیه دیگر تلاش کنید`); + return; + } + + setIsLoading(true); + try { + await apiClient.post("/api/auth/login/", { phone_number: phoneNumber }); + Alert.alert("کد مجدداً ارسال شد"); + setTimeLeft(120); + setCanResend(false); + } catch (error: any) { + Alert.alert("خطا", "مشکل در ارسال مجدد کد"); + } finally { + setIsLoading(false); + } + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs < 10 ? "0" : ""}${secs}`; + }; + + const handleFocus = () => { + setTimeout(() => { + scrollViewRef.current?.scrollTo({ y: 120, animated: true }); + }, 100); + }; + + const styles = useMemo( + () => authStyles(theme, focusedField), + [theme, focusedField], + ); + + return ( + + + + + + + + + + + + + + + + تایید کد + + + + + کد تایید برای شماره {phoneNumber} ارسال شد + + + کد تایید + + + { + setFocusedField("otp"); + handleFocus(); + }} + onBlur={() => setFocusedField(null)} + keyboardType="number-pad" + maxLength={6} + autoFocus + textAlign="center" + /> + + + + {!isLoading && ( + + )} + + {isLoading ? "در حال تایید..." : "تایید کد"} + + + + + + + {!canResend + ? `ارسال مجدد کد (${formatTime(timeLeft)})` + : "ارسال مجدد کد"} + + + + + + + ); +} diff --git a/src/app/(auth)/SignUp.tsx b/src/app/(auth)/SignUp.tsx new file mode 100644 index 0000000..7161f83 --- /dev/null +++ b/src/app/(auth)/SignUp.tsx @@ -0,0 +1,244 @@ +import { useTheme } from "@/hooks/use-theme"; +import apiClient from "@/services/api"; +import { authStyles } from "@/styles/auth.styles"; +import { Feather } from "@expo/vector-icons"; +import { router } from "expo-router"; +import React, { useMemo, useRef, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +const SignUpScreen = () => { + const theme = useTheme(); + const [phoneNumber, setPhoneNumber] = useState(""); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [focusedField, setFocusedField] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const scrollViewRef = useRef(null); + + const isPhoneValid = phoneNumber.trim().length >= 10; + const isUsernameValid = username.trim().length >= 3; + const isEmailValid = email.includes("@") && email.includes("."); + + const handleBack = () => { + router.back(); + }; + + const handleSignUp = async () => { + if (!isPhoneValid) { + Alert.alert("خطا", "شماره موبایل معتبر وارد کنید (حداقل ۱۰ رقم)"); + return; + } + if (!isUsernameValid) { + Alert.alert("خطا", "نام کاربری باید حداقل ۳ کاراکتر باشد"); + return; + } + if (!isEmailValid) { + Alert.alert("خطا", "ایمیل معتبر وارد کنید"); + return; + } + + setIsLoading(true); + try { + await apiClient.post("/api/auth/register/", { + phone_number: phoneNumber, + username: username, + email: email, + }); + Alert.alert( + "ثبت‌نامه موفق", + "حساب کاربری شما ایجاد شد. لطفاً وارد شوید.", + ); + router.push("/(auth)/login"); + } catch (error: any) { + Alert.alert( + "خطا", + error.response?.data?.message || "مشکلی در ثبت‌نامه پیش آمد", + ); + } finally { + setIsLoading(false); + } + }; + + const handleLogin = () => { + router.push("/(auth)/login"); + }; + + const handleFocus = (fieldName: string) => { + setFocusedField(fieldName); + let yOffset = 0; + switch (fieldName) { + case "phone": + yOffset = 200; + break; + case "username": + yOffset = 280; + break; + case "email": + yOffset = 360; + break; + } + setTimeout(() => { + scrollViewRef.current?.scrollTo({ y: yOffset, animated: true }); + }, 100); + }; + + const styles = useMemo( + () => authStyles(theme, focusedField), + [theme, focusedField], + ); + + return ( + + + + + + + + + + {/* کارت ثبت‌نام */} + + + + + + + ایجاد حساب کاربری + + + + {/* شماره موبایل */} + شماره موبایل + + + handleFocus("phone")} + onBlur={() => setFocusedField(null)} + keyboardType="phone-pad" + autoCapitalize="none" + textAlign="right" + /> + + + {/* نام کاربری */} + نام کاربری + + + handleFocus("username")} + onBlur={() => setFocusedField(null)} + autoCapitalize="none" + textAlign="right" + /> + + + {/* ایمیل */} + ایمیل سازمانی + + + handleFocus("email")} + onBlur={() => setFocusedField(null)} + keyboardType="email-address" + autoCapitalize="none" + textAlign="right" + /> + + + + + {isLoading ? "در حال ثبت‌نام..." : "ثبت‌نام"} + + + + + + حساب کاربری دارید؟ + + وارد شوید + + + + + + + ); +}; + +export default SignUpScreen; diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx new file mode 100644 index 0000000..d3ed780 --- /dev/null +++ b/src/app/(auth)/_layout.tsx @@ -0,0 +1,13 @@ +import { Tabs, Stack } from "expo-router"; + +export default function AuthLayout() { + return ( + + + + + + + + ); +} \ No newline at end of file diff --git a/src/app/(auth)/homeLogin.tsx b/src/app/(auth)/homeLogin.tsx new file mode 100644 index 0000000..c43ba3a --- /dev/null +++ b/src/app/(auth)/homeLogin.tsx @@ -0,0 +1,190 @@ +import { router } from "expo-router"; +import React, { useMemo } from "react"; +import { + ScrollView, + StatusBar, + Text, + TouchableOpacity, + View, + DimensionValue, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useTheme } from "@/hooks/use-theme"; +import { createWelcomeStyles } from "@/styles/auth.styles"; + +interface CardItem { + id: string; + dotColor: string; + line1WidthPercent: number; + line2WidthPercent: number; + tagText: string; + tagBg: string; + tagColor: string; + isAccent: boolean; +} + +const WelcomeScreen = () => { + const theme = useTheme(); + const isDark = theme.background === '#0D0D14'; + + const getCardColors = (accentType: 'primary' | 'green' | 'orange' | 'pink') => { + if (isDark) { + switch (accentType) { + case 'primary': + return { tagBg: '#1a1635', tagColor: '#8B7FFF', dotColor: theme.accent }; + case 'green': + return { tagBg: '#0a1f18', tagColor: '#5DCAA5', dotColor: '#1D9E75' }; + case 'orange': + return { tagBg: '#1e1500', tagColor: '#EF9F27', dotColor: '#EF9F27' }; + case 'pink': + return { tagBg: '#1a0d12', tagColor: '#ED93B1', dotColor: '#D4537E' }; + } + } else { + switch (accentType) { + case 'primary': + return { tagBg: '#EEEEFF', tagColor: '#6C63FF', dotColor: theme.accent }; + case 'green': + return { tagBg: '#E6F7F0', tagColor: '#1D9E75', dotColor: '#1D9E75' }; + case 'orange': + return { tagBg: '#FFF3E6', tagColor: '#EF9F27', dotColor: '#EF9F27' }; + case 'pink': + return { tagBg: '#FFEEF2', tagColor: '#D4537E', dotColor: '#D4537E' }; + } + } + return { tagBg: theme.backgroundElement, tagColor: theme.textSecondary, dotColor: theme.textSecondary }; + }; + + const cardsData: CardItem[] = [ + { + id: "1", + dotColor: getCardColors('primary').dotColor, + line1WidthPercent: 85, + line2WidthPercent: 60, + tagText: "طراحی", + tagBg: getCardColors('primary').tagBg, + tagColor: getCardColors('primary').tagColor, + isAccent: true, + }, + { + id: "2", + dotColor: getCardColors('green').dotColor, + line1WidthPercent: 75, + line2WidthPercent: 45, + tagText: "انجام شد", + tagBg: getCardColors('green').tagBg, + tagColor: getCardColors('green').tagColor, + isAccent: false, + }, + { + id: "3", + dotColor: getCardColors('orange').dotColor, + line1WidthPercent: 65, + line2WidthPercent: 40, + tagText: "بررسی", + tagBg: getCardColors('orange').tagBg, + tagColor: getCardColors('orange').tagColor, + isAccent: false, + }, + { + id: "4", + dotColor: getCardColors('pink').dotColor, + line1WidthPercent: 80, + line2WidthPercent: 50, + tagText: "راه‌اندازی", + tagBg: getCardColors('pink').tagBg, + tagColor: getCardColors('pink').tagColor, + isAccent: true, + }, + ]; + + const percent = (value: number): DimensionValue => `${value}%` as DimensionValue; + + const dynamicStyles = useMemo( + () => createWelcomeStyles(theme, isDark), + [theme, isDark] + ); + + const renderMiniCard = (item: CardItem) => { + const cardStyle = item.isAccent + ? [dynamicStyles.miniCard, dynamicStyles.miniCardAccent] + : dynamicStyles.miniCard; + + return ( + + + + + + + + + {item.tagText} + + + + ); + }; + + const statusBarStyle = isDark ? 'light-content' : 'dark-content'; + + return ( + + + + + + + + Plania + + + + {cardsData.map(renderMiniCard)} + + + + + + کار را مدیریت کن،{"\n"} + زیبا. + + + با تیم خود در زمان واقعی برنامه‌ریزی،{"\n"}پیگیری و همکاری کن. + + + + router.push("/(auth)/SignUp")} + > + شروع رایگان + + + router.push("/(auth)/login")} + > + حساب کاربری دارم + + + + + ); +}; + +export default WelcomeScreen; \ No newline at end of file diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx new file mode 100644 index 0000000..e9f7cc7 --- /dev/null +++ b/src/app/(auth)/login.tsx @@ -0,0 +1,168 @@ +import { useTheme } from "@/hooks/use-theme"; +import apiClient from "@/services/api"; +import { authStyles } from "@/styles/auth.styles"; +import { Feather } from "@expo/vector-icons"; +import { router } from "expo-router"; +import React, { useMemo, useRef, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +const LoginScreen = () => { + const [phoneNumber, setPhoneNumber] = useState(""); + const [focusedField, setFocusedField] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const theme = useTheme(); + + const scrollViewRef = useRef(null); + + const handleBack = () => { + router.back(); + }; + + const handleSendOTP = async () => { + if (!phoneNumber) { + Alert.alert("خطا", "لطفا شماره موبایل خود را وارد کنید"); + return; + } + + setIsLoading(true); + try { + await apiClient.post("/api/auth/login/", { phone_number: phoneNumber }); + Alert.alert("کد تایید ارسال شد", "لطفا کد ارسال شده را وارد کنید"); + + router.push({ + pathname: "/OTPLogin", + params: { phoneNumber: phoneNumber }, + }); + } catch (error: any) { + console.log(error); + Alert.alert( + "خطا", + error.response?.data?.message || "مشکلی در ارسال کد پیش آمد", + ); + } finally { + setIsLoading(false); + } + }; + + const handleSignUp = () => { + router.push("/(auth)/SignUp"); + }; + + const handleFocus = () => { + setTimeout(() => { + scrollViewRef.current?.scrollTo({ y: 120, animated: true }); + }, 100); + }; + + const styles = useMemo( + () => authStyles(theme, focusedField), + [theme, focusedField], + ); + + return ( + + + + {/* دکمه بازگشت */} + + + + + + + + + + + + + ورود به حساب + + + + شماره موبایل + + + { + setFocusedField("phone"); + handleFocus(); + }} + onBlur={() => setFocusedField(null)} + keyboardType="phone-pad" + autoCapitalize="none" + textAlign="right" + /> + + + + + {isLoading ? "در حال ارسال..." : "ورود"} + + + + + + حساب کاربری ندارید؟ + + ایجاد حساب رایگان + + + + + + + ); +}; + +export default LoginScreen; diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..dfac9be --- /dev/null +++ b/src/app/(tabs)/_layout.tsx @@ -0,0 +1,5 @@ +import AppTabs from "@/components/app-tabs"; + +export default function TabsLayout() { + return ; +} diff --git a/src/app/explore.tsx b/src/app/(tabs)/explore.tsx similarity index 58% rename from src/app/explore.tsx rename to src/app/(tabs)/explore.tsx index f08c5d3..e38f486 100644 --- a/src/app/explore.tsx +++ b/src/app/(tabs)/explore.tsx @@ -1,18 +1,31 @@ -import { Image } from 'expo-image'; -import { SymbolView } from 'expo-symbols'; -import React from 'react'; -import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Image } from "expo-image"; +import { SymbolView } from "expo-symbols"; +import React, { useLayoutEffect } from "react"; +import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ExternalLink } from '@/components/external-link'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Collapsible } from '@/components/ui/collapsible'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; +import { ExternalLink } from "@/components/external-link"; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { Collapsible } from "@/components/ui/collapsible"; +import { WebBadge } from "@/components/web-badge"; +import { BottomTabInset, MaxContentWidth, Spacing } from "@/constants/theme"; +import { useTheme } from "@/hooks/use-theme"; +import { useNavigation } from "expo-router"; export default function TabTwoScreen() { + const navigation = useNavigation(); + useLayoutEffect(() => { + navigation.setOptions({ + title: "explore", + tabBarIcon: ({ color, size }: { color: string; size: number }) => ( + + ), + }); + }, [navigation]); const safeAreaInsets = useSafeAreaInsets(); const insets = { ...safeAreaInsets, @@ -37,12 +50,13 @@ export default function TabTwoScreen() { + contentContainerStyle={[styles.contentContainer, contentPlatformStyle]} + > Explore - This starter app includes example{'\n'}code to help you get started. + This starter app includes example{"\n"}code to help you get started. @@ -51,7 +65,11 @@ export default function TabTwoScreen() { Expo documentation @@ -62,11 +80,13 @@ export default function TabTwoScreen() { - This app has two screens: src/app/index.tsx and{' '} + This app has two screens:{" "} + src/app/index.tsx and{" "} src/app/explore.tsx - The layout file in src/app/_layout.tsx sets up + The layout file in{" "} + src/app/_layout.tsx sets up the tab navigator. @@ -75,14 +95,18 @@ export default function TabTwoScreen() { - + - You can open this project on Android, iOS, and the web. To open the web version, - press w in the terminal running this - project. + You can open this project on Android, iOS, and the web. To open + the web version, press{" "} + w in the terminal + running this project. @@ -90,11 +114,15 @@ export default function TabTwoScreen() { - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for different - screen densities. + For static images, you can use the{" "} + @2x and{" "} + @3x suffixes to provide files + for different screen densities. - + Learn more @@ -102,9 +130,10 @@ export default function TabTwoScreen() { - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect what the - user's current color scheme is, and so you can adjust UI colors accordingly. + This template has light and dark mode support. The{" "} + useColorScheme() hook lets + you inspect what the user's current color scheme is, and so + you can adjust UI colors accordingly. Learn more @@ -113,14 +142,17 @@ export default function TabTwoScreen() { - This template includes an example of an animated component. The{' '} - src/components/ui/collapsible.tsx component uses - the powerful react-native-reanimated library to - animate opening this hint. + This template includes an example of an animated component. The{" "} + + src/components/ui/collapsible.tsx + {" "} + component uses the powerful{" "} + react-native-reanimated{" "} + library to animate opening this hint. - {Platform.OS === 'web' && } + {Platform.OS === "web" && } ); @@ -131,8 +163,8 @@ const styles = StyleSheet.create({ flex: 1, }, contentContainer: { - flexDirection: 'row', - justifyContent: 'center', + flexDirection: "row", + justifyContent: "center", }, container: { maxWidth: MaxContentWidth, @@ -140,24 +172,24 @@ const styles = StyleSheet.create({ }, titleContainer: { gap: Spacing.three, - alignItems: 'center', + alignItems: "center", paddingHorizontal: Spacing.four, paddingVertical: Spacing.six, }, centerText: { - textAlign: 'center', + textAlign: "center", }, pressed: { opacity: 0.7, }, linkButton: { - flexDirection: 'row', + flexDirection: "row", paddingHorizontal: Spacing.four, paddingVertical: Spacing.two, borderRadius: Spacing.five, - justifyContent: 'center', + justifyContent: "center", gap: Spacing.one, - alignItems: 'center', + alignItems: "center", }, sectionsWrapper: { gap: Spacing.five, @@ -165,10 +197,10 @@ const styles = StyleSheet.create({ paddingTop: Spacing.three, }, collapsibleContent: { - alignItems: 'center', + alignItems: "center", }, imageTutorial: { - width: '100%', + width: "100%", aspectRatio: 296 / 171, borderRadius: Spacing.three, marginTop: Spacing.two, @@ -176,6 +208,6 @@ const styles = StyleSheet.create({ imageReact: { width: 100, height: 100, - alignSelf: 'center', + alignSelf: "center", }, }); diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..7c2aa06 --- /dev/null +++ b/src/app/(tabs)/index.tsx @@ -0,0 +1,143 @@ +import * as Device from "expo-device"; +import { Button, Platform, StyleSheet, ToastAndroid } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { AnimatedIcon } from "@/components/animated-icon"; +import { HintRow } from "@/components/hint-row"; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { WebBadge } from "@/components/web-badge"; +import { BottomTabInset, MaxContentWidth, Spacing } from "@/constants/theme"; +import { useEffect, useLayoutEffect, useState } from "react"; +import apiClient from "@/services/api"; +import { useAuth } from "@/constants/AuthContext"; +import { Image } from "react-native"; +import { useNavigation } from "expo-router"; + +interface Profile { + id: number; + name: string; + bio: string; + phone_number: string; + username: string; + email: string; + profile_picture: string; + color: string; +} + +function getDevMenuHint() { + if (Platform.OS === "web") { + return use browser devtools; + } + if (Device.isDevice) { + return ( + + shake device or press m in terminal + + ); + } + const shortcut = Platform.OS === "android" ? "cmd+m (or ctrl+m)" : "cmd+d"; + return ( + + press {shortcut} + + ); +} + +export default function HomeScreen() { + const navigation = useNavigation(); + useLayoutEffect(() => { + navigation.setOptions({ + title: "خانه", + tabBarIcon: ({ color, size }: { color: string; size: number}) => ( + + ), + }); + }, [navigation]); + const { signOut } = useAuth(); + + const [profile, setProfile] = useState(); + useEffect(() => { + apiClient + .get("api/auth/profile/") + .then((res) => { + if (res.data) { + setProfile(res.data); + } + }) + .catch((err) => {}); + }, []); + return ( + + + + + + {profile?.name} + + + phone : {profile?.phone_number} + + + + + get started + + + + src/app/index.tsx} + /> + + npm run reset-project} + /> + + + {Platform.OS === "web" && } +