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 (
)}
+
{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 (