diff --git a/client/package.json b/client/package.json index f51a9b2..d4ac439 100644 --- a/client/package.json +++ b/client/package.json @@ -46,6 +46,7 @@ "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-svg": "15.11.2", + "react-native-webview": "13.13.5", "socket.io-client": "2.3.1", "tailwind-merge": "^3.3.0", "tailwindcss": "3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 200c003..51462ed 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,13 +15,17 @@ import { useAuthStore } from "./shared/stores/auth.store"; import { useGeolocationStore } from "./shared/stores/location.store"; import { logger } from "./shared/instances/logger.instance"; import React from "react"; -import { View, Text } from "react-native"; import { ModalProvider } from "./shared/providers/modal-provider"; import { OverlayProvider } from "./shared/providers/overlay.provider"; import { SocketErrorModalBridge } from "./shared/instances/socket.instance"; +import { ErrorBoundary } from "./shared/error-boundary"; SplashScreen.preventAutoHideAsync(); +ErrorUtils.setGlobalHandler((error, isFatal) => { + logger.error("ui", "Global error:", error, isFatal); +}); + const queryClient = new QueryClient(); export default function App() { @@ -73,50 +77,6 @@ export default function App() { return null; } - class ErrorBoundary extends React.Component< - { children: React.ReactNode }, - { hasError: boolean } - > { - constructor(props: any) { - super(props); - this.state = { hasError: false }; - } - static getDerivedStateFromError() { - return { hasError: true }; - } - componentDidCatch(err: any) { - console.error("Unhandled UI error:", err); - } - render() { - if (this.state.hasError) { - return ( - - - - - - Произошла ошибка. Приложение продолжает работу. Вернитесь - назад. - - - - - - ); - } - return this.props.children as any; - } - } - return ( diff --git a/client/src/components/overlays/task-point.overlay.tsx b/client/src/components/overlays/task-point.overlay.tsx index ed9427c..ecad82d 100644 --- a/client/src/components/overlays/task-point.overlay.tsx +++ b/client/src/components/overlays/task-point.overlay.tsx @@ -82,10 +82,10 @@ export const TaskPointOverlay = ({ {/* Content Sleeve */} - + {taskPoint.title} - {taskPoint.description} + {taskPoint.description} {completedTaskPoint ? ( @@ -104,13 +104,13 @@ export const TaskPointOverlay = ({ {/* Takes remaining space */} {taskPoint.title} {taskPoint.task} diff --git a/client/src/components/screens/main/game/route.screen.tsx b/client/src/components/screens/main/game/route.screen.tsx index 1e34ce7..0bd1c30 100644 --- a/client/src/components/screens/main/game/route.screen.tsx +++ b/client/src/components/screens/main/game/route.screen.tsx @@ -115,7 +115,7 @@ export const RouteScreen = () => { // Show only free/accessible routes for now (no ownership info available on client) const routes = useMemo(() => { const list = isFetched && data !== undefined ? data : []; - return list.filter((r) => r.priceRoubles === 0); + return list.filter((r) => r.bought === true || r.priceRoubles === 0); }, [isFetched, data]); const [index, setIndex] = useState(0); diff --git a/client/src/components/screens/main/shop.screen.tsx b/client/src/components/screens/main/shop.screen.tsx index 2ff2796..89c9a17 100644 --- a/client/src/components/screens/main/shop.screen.tsx +++ b/client/src/components/screens/main/shop.screen.tsx @@ -12,7 +12,6 @@ import { RoutesWidget } from "@/components/widgets/shop/routes"; import { SafeAreaView } from "react-native-safe-area-context"; import { Header } from "@/components/ui/header"; import { StatusBar } from "expo-status-bar"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useStyles } from "@/shared/api/hooks/useStyles"; import { useAuthStore } from "@/shared/stores/auth.store"; import { setAvatar } from "@/shared/api/styles.api"; @@ -21,6 +20,9 @@ import { Style } from "@/shared/interfaces/styles/styles"; import { useModal } from "@/shared/hooks/useModal"; import { logger } from "@/shared/instances/logger.instance"; import { useRoutes } from "@/shared/api/hooks/useRoutes"; +import { WebView } from "react-native-webview"; +import { buyRoute } from "@/shared/api/routes.api"; +import { Route } from "@/shared/interfaces/route"; export const ShopScreen = () => { const navigation = useNavigation>(); @@ -28,17 +30,20 @@ export const ShopScreen = () => { selectedSection: "Аватарки", }); + const [url, setUrl] = useState(null); + const [selectedSeciton, setSelectedSection] = useState("Аватарки"); const { data: avatars, isFetched: isAvatarsFetched, buyStyle, + updateStyles, } = useStyles({ type: "avatar", }); - const { data, isFetched } = useRoutes(); + const { data, isFetched, updateRoutes } = useRoutes(); const routes = useMemo( () => (isFetched && data !== undefined ? data : []), [isFetched, data] @@ -51,7 +56,7 @@ export const ShopScreen = () => { navigation.goBack(); }; - const handleButton = async (avatar: Style) => { + const handleAvatarButton = async (avatar: Style) => { if (avatar.bought) { try { await setAvatar(avatar.id); @@ -79,66 +84,99 @@ export const ShopScreen = () => { }); } } else { - await buyStyle(avatar.id); + var result = await buyStyle(avatar.id); + setUrl(result.confirmationUrl); } }; + const handleRouteButton = async (r: Route) => { + var result = await buyRoute(r.id); + setUrl(result.confirmationUrl); + }; + return ( - - - - - - - - - + {url !== null ? ( + { + if (state.url.includes("success")) { + setUrl(null); -
+ updateStyles(); + updateRoutes(); - - setModalOpen(false), + }, + ], + }); + } + }} + /> + ) : ( + + + + + + + + + + +
- - {sectionRef.current?.selectedSection === "Аватарки" && - isAvatarsFetched && ( - ({ - avatar: avatar.style.url, - title: avatar.title, - withButton: true, - bought: avatar.bought, - buttonAction: async () => { - await handleButton(avatar); - }, - subtitle: - avatar.priceRoubles == 0 - ? "Бесплатно" - : `${avatar.priceRoubles} ₽`, - disabled: avatar.id === user?.styles?.avatarId, + + + + + {sectionRef.current?.selectedSection === "Аватарки" && + isAvatarsFetched && ( + ({ + avatar: avatar.style.url, + title: avatar.title, + withButton: true, + bought: avatar.bought, + buttonAction: async () => await handleAvatarButton(avatar), + subtitle: + avatar.priceRoubles == 0 + ? "Бесплатно" + : `${avatar.priceRoubles} ₽`, + disabled: avatar.id === user?.styles?.avatarId, + }))} + className="pb-[70px]" + /> + )} + + {sectionRef.current?.selectedSection === "Маршруты" && ( + r.bought == false).map((r) => ({ + route: r, + disabled: false, + buttonAction: async () => await handleRouteButton(r), }))} - className="pb-[70px]" /> )} - - {sectionRef.current?.selectedSection === "Маршруты" && ( - ({ - route: r, - disabled: false, - }))} - /> - )} - - + + + )} ); diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index a9ab741..74c1f73 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -1,6 +1,7 @@ -import { Pressable, Text } from "react-native"; +import { Pressable, StyleProp, Text, ViewStyle } from "react-native"; import { cva, type VariantProps } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; +import React from "react"; const button = { variant: { @@ -39,12 +40,19 @@ interface ButtonProps extends VariantProps { className?: string; onPress?: () => void; text: string; - variant?: "default" | "disabled" | "invisible"; + style?: StyleProp; } -export const Button = ({ className, onPress, text, variant }: ButtonProps) => { +export const Button = ({ + className, + onPress, + text, + variant, + style, +}: ButtonProps) => { return ( diff --git a/client/src/components/ui/checkbox-input.tsx b/client/src/components/ui/checkbox-input.tsx new file mode 100644 index 0000000..04bb719 --- /dev/null +++ b/client/src/components/ui/checkbox-input.tsx @@ -0,0 +1,36 @@ +import { Pressable, View, Text } from "react-native"; +import { twMerge } from "tailwind-merge"; +import { Icons } from './icons/icons'; + +interface CheckboxInputProps { + className?: string; + label?: string; + checked: boolean; + onChange: (checked: boolean) => void; +} + +export const CheckboxInput = ({ + className, + label, + checked, + onChange, + ...props +}: CheckboxInputProps) => { + return ( + onChange(!checked)} + {...props} + > + + {checked && } + + {label && {label}} + + ); +}; diff --git a/client/src/components/ui/icons/icons.tsx b/client/src/components/ui/icons/icons.tsx index 0157ddf..a5126e0 100644 --- a/client/src/components/ui/icons/icons.tsx +++ b/client/src/components/ui/icons/icons.tsx @@ -176,6 +176,24 @@ export const Icons = { ); }, + Check: () => { + return ( + + + + ); + }, + Chat: () => { return ( diff --git a/client/src/components/widgets/map.tsx b/client/src/components/widgets/map.tsx index ca063bb..3a3538b 100644 --- a/client/src/components/widgets/map.tsx +++ b/client/src/components/widgets/map.tsx @@ -1,6 +1,5 @@ import { MAPBOX_STYLE_URL, MAPBOX_TOKEN } from "@/constants"; import Mapbox, { MapView } from "@rnmapbox/maps"; -import { logger } from "@/shared/instances/logger.instance"; import { twMerge } from "tailwind-merge"; Mapbox.setAccessToken(MAPBOX_TOKEN); @@ -11,25 +10,12 @@ interface MapProps { } export const Map = ({ children, className }: MapProps) => { - const fallbackStyle = "mapbox://styles/mapbox/dark-v11"; - const styleUrl = - typeof MAPBOX_STYLE_URL === "string" && MAPBOX_STYLE_URL.length > 0 - ? MAPBOX_STYLE_URL - : fallbackStyle; - return ( - logger.error( - "ui", - "Mapbox map error", - e?.nativeEvent?.message ?? e?.message ?? e - ) - } + styleURL={MAPBOX_STYLE_URL} className={twMerge("flex-1", className)} > {children} diff --git a/client/src/components/widgets/modal-card.tsx b/client/src/components/widgets/modal-card.tsx index 17b4fd2..85b71f2 100644 --- a/client/src/components/widgets/modal-card.tsx +++ b/client/src/components/widgets/modal-card.tsx @@ -1,6 +1,7 @@ -import { View, Text, Pressable } from "react-native"; +import { View, Text, Animated, Easing } from "react-native"; import { BlurView } from "expo-blur"; import { twMerge } from "tailwind-merge"; +import { useEffect, useRef } from "react"; import { Button } from "../ui/button"; interface ModalOption { @@ -16,23 +17,50 @@ export interface ModalCardProps { buttons: [ModalOption] | [ModalOption, ModalOption]; } +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView); + export const ModalCard = ({ className, title, subtitle, buttons, }: ModalCardProps) => { + const blurAnim = useRef(new Animated.Value(0)).current; // for blur intensity + const scaleAnim = useRef(new Animated.Value(0.95)).current; // for modal scale + + useEffect(() => { + Animated.parallel([ + Animated.timing(blurAnim, { + toValue: 80, // final blur intensity + duration: 300, + easing: Easing.out(Easing.ease), + useNativeDriver: false, // must be false for blur intensity + }), + Animated.spring(scaleAnim, { + toValue: 1, + friction: 6, + tension: 80, + useNativeDriver: true, + }), + ]).start(); + }, []); + return ( - - @@ -48,6 +76,7 @@ export const ModalCard = ({ )} + {buttons.map((btn, index) => ( ))} - + ); }; diff --git a/client/src/components/widgets/shop/routes.tsx b/client/src/components/widgets/shop/routes.tsx index 988b5ff..bf747c8 100644 --- a/client/src/components/widgets/shop/routes.tsx +++ b/client/src/components/widgets/shop/routes.tsx @@ -1,12 +1,11 @@ import { View } from "react-native"; import { RouteCard } from "../route-card"; import { Route } from "@/shared/interfaces/route"; -import { buyRoute } from "@/shared/api/routes.api"; import { useModal } from "@/shared/hooks/useModal"; -import { logger } from "@/shared/instances/logger.instance"; export interface RouteData { route: Route; + buttonAction?: () => void; disabled?: boolean; } @@ -15,34 +14,6 @@ interface RoutesWidgetProps { } export const RoutesWidget = (props: RoutesWidgetProps) => { - const { setModalOpen } = useModal(); - - const handleBuy = async (r: Route) => { - try { - await buyRoute(r.id); - setModalOpen({ - title: "Маршрут куплен", - subtitle: `Теперь доступен: ${r.title}`, - buttons: [ - { text: "Ок", type: "primary", onClick: () => setModalOpen(false) }, - ], - }); - } catch (e: any) { - logger.error("ui", "Error buying route:", e); - setModalOpen({ - title: "Ошибка", - subtitle: "Не удалось купить маршрут", - buttons: [ - { - text: "Закрыть", - type: "primary", - onClick: () => setModalOpen(false), - }, - ], - }); - } - }; - return ( {props.routes.map((route, index) => ( @@ -51,7 +22,7 @@ export const RoutesWidget = (props: RoutesWidgetProps) => { title={route.route.title} route={route.route} disabled={route.disabled} - buttonAction={() => handleBuy(route.route)} + buttonAction={route.buttonAction} /> ))} diff --git a/client/src/components/widgets/signup-widget.tsx b/client/src/components/widgets/signup-widget.tsx index 435528d..419aaa2 100644 --- a/client/src/components/widgets/signup-widget.tsx +++ b/client/src/components/widgets/signup-widget.tsx @@ -18,30 +18,51 @@ import { TextInput } from "../ui/textinput"; import { AvatarPicker } from "./avatar-picker"; import { logger } from "@/shared/instances/logger.instance"; import { useModal } from "@/shared/hooks/useModal"; +import { CheckboxInput } from "../ui/checkbox-input"; +import { DEFAULT_AVATAR_ID } from "@/constants"; export const SignUpWidget = () => { const { user, token, login, setToken, setUser } = useAuthStore(); + const [agreed, setAgreed] = useState(false); const [name, setName] = useState(""); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [step, setStep] = useState<0 | 1>(0); - const [selectedAvatar, setSelectedAvatar] = useState(null); + const [selectedAvatar, setSelectedAvatar] = + useState(DEFAULT_AVATAR_ID); const { setModalOpen } = useModal(); - const { data: avatars, isLoading } = useStyles({ + const { + data: avatars, + isLoading, + refetch, + } = useStyles({ type: "avatar", bought: true, }); const handleRegister = async () => { + if (agreed === false) { + setModalOpen({ + title: "Условия использования", + subtitle: "Пожалуйста, прими условия использования", + buttons: [ + { text: "Ок", type: "primary", onClick: () => setModalOpen(false) }, + ], + }); + return; + } + try { const response = await registerRequest(email, username, password, name); setToken(response.token); setUser(response.user); setStep(1); + refetch(); + await setAvatar(selectedAvatar?.toString()); } catch (error: any) { logger.error("ui", "register failed", error?.response?.data ?? error); setModalOpen({ @@ -135,11 +156,21 @@ export const SignUpWidget = () => { onChangeText={setPassword} className="w-[90%] mx-auto" /> + + setAgreed(state)} + /> + + Я даю согласие на обработку персональных данных + + diff --git a/client/src/constants.ts b/client/src/constants.ts index 15d83ac..2e30d03 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -33,6 +33,8 @@ export const ACTIVITIES: Record<"wins" | "matches", ImageSourcePropType> = { export const SHOP_SECTIONS = ["Аватарки", "Маршруты"] as const; +export const DEFAULT_AVATAR_ID = "1"; + export const DEFAULT_MAP_CAMERA_LOCATION: [number, number] = [ 30.308354, 59.955536, ] as const; diff --git a/client/src/shared/api/hooks/useRoutes.ts b/client/src/shared/api/hooks/useRoutes.ts index f655bea..67740df 100644 --- a/client/src/shared/api/hooks/useRoutes.ts +++ b/client/src/shared/api/hooks/useRoutes.ts @@ -1,9 +1,20 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getRoutes } from "../routes.api"; export const useRoutes = () => { - return useQuery({ + const queryClient = useQueryClient(); + + const routesQuery = useQuery({ queryKey: ["routes"], queryFn: () => getRoutes(), }); + + const updateRoutes = () => { + queryClient.invalidateQueries({ queryKey: ["styles"] }); + }; + + return { + ...routesQuery, + updateRoutes, + }; }; diff --git a/client/src/shared/api/hooks/useStyles.ts b/client/src/shared/api/hooks/useStyles.ts index 5b4c361..f27ef08 100644 --- a/client/src/shared/api/hooks/useStyles.ts +++ b/client/src/shared/api/hooks/useStyles.ts @@ -12,19 +12,21 @@ export const useStyles = (params: { type?: StyleType; bought?: boolean }) => { const buyMutation = useMutation({ mutationFn: (styleId: string) => buyStyle(styleId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["styles"] }); - }, }); const getStyle = (styleId: string) => { return stylesQuery.data?.find((x) => x.id == styleId); }; + const updateStyles = () => { + queryClient.invalidateQueries({ queryKey: ["styles"] }); + }; + return { ...stylesQuery, getStyle, buyStyle: buyMutation.mutateAsync, isBuying: buyMutation.isPending, + updateStyles, }; }; diff --git a/client/src/shared/api/routes.api.ts b/client/src/shared/api/routes.api.ts index 5adb71c..c37a923 100644 --- a/client/src/shared/api/routes.api.ts +++ b/client/src/shared/api/routes.api.ts @@ -1,13 +1,20 @@ import { api } from "../instances/axios.instance"; import { Route } from "../interfaces/route"; +import { PaymentResult } from "../interfaces/styles/payment"; export const getRoutes = async (): Promise => { const response = await api.get("api/v1/route"); return response.data; }; -export const buyRoute = async (routeId: string): Promise => { - await api.post(`api/v1/route/${routeId}/buy`); +export const buyRoute = async (routeId: string): Promise => { + let result = await api.post( + `api/v1/payment/route/${routeId}`, + { + returnUrl: "sightquest://main/shop", + } + ); + return result.data; }; export const getMyRoutes = async (): Promise => { diff --git a/client/src/shared/api/styles.api.ts b/client/src/shared/api/styles.api.ts index e9ac5d9..cbf66bb 100644 --- a/client/src/shared/api/styles.api.ts +++ b/client/src/shared/api/styles.api.ts @@ -1,4 +1,5 @@ import { api } from "../instances/axios.instance"; +import { PaymentResult } from "../interfaces/styles/payment"; import { Style, StyleType } from "../interfaces/styles/styles"; export const getStyles = async (params: { @@ -9,9 +10,9 @@ export const getStyles = async (params: { return response.data; }; -export const buyStyle = async (styleId: string) => { - const response = await api.post(`api/v1/styles/id/${styleId}/buy`, { - styleId, +export const buyStyle = async (styleId: string): Promise => { + const response = await api.post(`api/v1/payment/style/${styleId}`, { + returnUrl: "sightquest://main/shop", }); return response.data; }; diff --git a/client/src/shared/error-boundary.tsx b/client/src/shared/error-boundary.tsx new file mode 100644 index 0000000..6ce9423 --- /dev/null +++ b/client/src/shared/error-boundary.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { View, Text } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { NavigationContainer } from "@react-navigation/native"; + +export class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: any) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(err: any) { + console.error("Unhandled UI error:", err); + } + + render() { + if (this.state.hasError) { + return ( + + + + + + Произошла ошибка. Приложение продолжает работу. Вернитесь + назад. + + + + + + ); + } + return this.props.children; + } +} diff --git a/client/src/shared/interfaces/route.ts b/client/src/shared/interfaces/route.ts index 9069183..1b95687 100644 --- a/client/src/shared/interfaces/route.ts +++ b/client/src/shared/interfaces/route.ts @@ -19,4 +19,5 @@ export interface Route { description: string; priceRoubles: number; taskPoints: TaskPoint[]; + bought: boolean; } diff --git a/client/src/shared/interfaces/styles/payment.ts b/client/src/shared/interfaces/styles/payment.ts new file mode 100644 index 0000000..b18b27d --- /dev/null +++ b/client/src/shared/interfaces/styles/payment.ts @@ -0,0 +1,11 @@ +export interface PaymentResult { + amountRoubles: number; + confirmationUrl: string; + createdAt: string; + id: string; + itemId: string; + status: "pending" | "succeeded" | "canceled"; + type: "style" | "subscription"; + userId: string; + yooKassaId: string; +} diff --git a/client/src/shared/providers/overlay.provider.tsx b/client/src/shared/providers/overlay.provider.tsx index 705298e..84d6e7d 100644 --- a/client/src/shared/providers/overlay.provider.tsx +++ b/client/src/shared/providers/overlay.provider.tsx @@ -62,6 +62,11 @@ export const OverlayProvider: React.FC<{ {Object.entries(overlayRegistry).map(([name, Component]) => { const key = name as OverlayName; const entry = state[key]; + + if (!entry?.visible) { + return null; + } + return (