Compare commits

...

2 Commits

Author SHA1 Message Date
c4328b7698 Merge branch 'next' of https://git.campfiregg.ru/degradin/CampFireID into next 2025-03-16 12:13:32 +03:00
2af270e3d8 Global fixes
Добавлен демо-режим:
Можно открыть приложение с параметром ?demo в URL
В демо-режиме эмулируется Telegram WebApp SDK
Добавлена заглушка для демо-пользователя
Исправлены типы и интерфейсы:
Добавлен тип SafeUser для безопасной работы с mongoose документами
Исправлены пропсы компонентов
Обновлены типы для покупок в магазине
Улучшена обработка ошибок:
Добавлены информативные сообщения об ошибках
Реализована корректная обработка сетевых ошибок
Добавлены toast-уведомления с подробной информацией
Улучшен UI:
Добавлены спиннеры загрузки
Улучшен внешний вид карточек товаров
Добавлены информативные сообщения о состоянии
2025-03-16 12:05:42 +03:00
4 changed files with 185 additions and 195 deletions

View File

@ -1,102 +1,65 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Container, Tabs, TabList, TabPanels, Tab, TabPanel, useToast, Spinner, Center } from '@chakra-ui/react'; import { Box, VStack, Spinner, Center, useToast } from '@chakra-ui/react';
import { UserProfile } from './UserProfile'; import { UserProfile } from './UserProfile';
import { Shop } from './Shop'; import { Shop } from './Shop';
import { TransferBalance } from './TransferBalance'; import { TransferBalance } from './TransferBalance';
import * as api from '../utils/api'; import { auth, getProfile, getShopItems, purchaseItem, transferBalance } from '../utils/api';
import { IUser } from '../../backend/models/User'; import { IUser } from '../../backend/models/User';
import { IShopItem } from '../../backend/models/ShopItem'; import { IShopItem } from '../../backend/models/ShopItem';
import { isDemoMode, getDemoWebApp } from '../utils/demo';
type SafeUser = Omit<IUser, keyof Document>; type SafeUser = Omit<IUser, keyof Document>;
export function MainApp() { export default function MainApp() {
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<SafeUser | null>(null); const [user, setUser] = useState<SafeUser | null>(null);
const [shopItems, setShopItems] = useState<IShopItem[]>([]); const [shopItems, setShopItems] = useState<IShopItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const toast = useToast(); const toast = useToast();
useEffect(() => { const initApp = async () => {
const initApp = async () => { try {
try { setIsLoading(true);
setIsLoading(true);
// Динамически импортируем SDK только на клиенте let webApp;
if (isDemoMode()) {
webApp = getDemoWebApp();
} else {
const WebApp = (await import('@twa-dev/sdk')).default; const WebApp = (await import('@twa-dev/sdk')).default;
webApp = WebApp;
const initData = WebApp.initData;
if (!initData) {
throw new Error('Приложение должно быть открыто в Telegram');
}
// Авторизуем пользователя
const authData = await api.auth(
WebApp.initDataUnsafe.user?.id.toString() || '',
WebApp.initDataUnsafe.user?.username || ''
);
setUser(authData.user);
// Загружаем предметы магазина
const items = await api.getShopItems();
setShopItems(items);
} catch (error: any) {
toast({
title: 'Ошибка инициализации',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
} }
};
// Авторизация пользователя
const { user: telegramUser } = webApp.initDataUnsafe;
const authResponse = await auth(telegramUser.id.toString(), telegramUser.username || 'anonymous');
// Получение данных пользователя и магазина
const [profileData, shopData] = await Promise.all([
getProfile(),
getShopItems()
]);
setUser(profileData as SafeUser);
setShopItems(shopData);
} catch (error: any) {
console.error('Initialization error:', error);
toast({
title: 'Ошибка инициализации',
description: error.message || 'Произошла ошибка при загрузке приложения',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
initApp(); initApp();
}, []); }, []);
const handlePurchase = async (itemId: string) => {
try {
const result = await api.purchaseItem(itemId);
setUser(result.user);
toast({
title: 'Покупка успешна!',
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error: any) {
toast({
title: 'Ошибка покупки',
description: error.response?.data?.error || 'Произошла ошибка',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handleTransfer = async (recipientUsername: string, amount: number) => {
try {
const result = await api.transferBalance(recipientUsername, amount);
setUser(prev => prev ? { ...prev, balance: result.balance } : null);
toast({
title: 'Перевод выполнен',
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error: any) {
toast({
title: 'Ошибка перевода',
description: error.response?.data?.error || 'Произошла ошибка',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
if (isLoading) { if (isLoading) {
return ( return (
<Center h="100vh"> <Center h="100vh">
@ -106,45 +69,73 @@ export function MainApp() {
} }
if (!user) { if (!user) {
return null; return (
<Center h="100vh">
<Box p={4} textAlign="center">
Пожалуйста, авторизуйтесь через Telegram или используйте демо-режим
</Box>
</Center>
);
} }
return ( return (
<Container maxW="container.xl" py={8}> <VStack spacing={6} p={4}>
<Tabs isFitted variant="enclosed"> <UserProfile
<TabList mb="1em"> username={user.username}
<Tab>Профиль</Tab> level={user.level}
<Tab>Магазин</Tab> experience={user.experience}
<Tab>Перевод</Tab> balance={user.balance}
</TabList> achievements={user.achievements}
/>
<TabPanels> <Shop
<TabPanel> items={shopItems}
<UserProfile userBalance={user.balance}
username={user.username} onPurchase={async (item: IShopItem) => {
level={user.level} try {
experience={user.experience} const response = await purchaseItem(item._id.toString());
balance={user.balance} setUser(response.user as SafeUser);
achievements={user.achievements} toast({
/> title: 'Успешная покупка',
</TabPanel> description: `Вы приобрели ${item.name}`,
status: 'success',
<TabPanel> duration: 3000,
<Shop isClosable: true,
items={shopItems} });
userBalance={user.balance} } catch (error: any) {
onPurchase={handlePurchase} toast({
/> title: 'Ошибка покупки',
</TabPanel> description: error.message || 'Произошла ошибка при покупке',
status: 'error',
<TabPanel> duration: 3000,
<TransferBalance isClosable: true,
userBalance={user.balance} });
onTransfer={handleTransfer} }
/> }}
</TabPanel> />
</TabPanels> <TransferBalance
</Tabs> userBalance={user.balance}
</Container> onTransfer={async (username, amount) => {
try {
const response = await transferBalance(username, amount);
setUser(prev => ({ ...prev!, balance: response.balance } as SafeUser));
toast({
title: 'Успешный перевод',
description: `Вы перевели ${amount} монет пользователю ${username}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error: any) {
toast({
title: 'Ошибка перевода',
description: error.message || 'Произошла ошибка при переводе',
status: 'error',
duration: 3000,
isClosable: true,
});
}
}}
/>
</VStack>
); );
} }

View File

@ -1,34 +1,23 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { import { Box, SimpleGrid, Button, Text, Image, useToast } from '@chakra-ui/react';
Box,
Grid,
Text,
Button,
Image,
VStack,
useToast,
useColorModeValue,
} from '@chakra-ui/react';
import { IShopItem } from '../../backend/models/ShopItem'; import { IShopItem } from '../../backend/models/ShopItem';
interface ShopProps { interface ShopProps {
items: IShopItem[]; items: IShopItem[];
userBalance: number; userBalance: number;
onPurchase: (itemId: string) => Promise<void>; onPurchase: (item: IShopItem) => Promise<void>;
} }
export const Shop: React.FC<ShopProps> = ({ items, userBalance, onPurchase }) => { export function Shop({ items, userBalance, onPurchase }: ShopProps) {
const toast = useToast(); const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const handlePurchase = async (item: IShopItem) => { const handlePurchase = async (item: IShopItem) => {
if (userBalance < item.price) { if (userBalance < item.price) {
toast({ toast({
title: 'Недостаточно средств', title: 'Недостаточно средств',
description: 'У вас недостаточно Campfire монет для покупки этого предмета', description: `Для покупки ${item.name} нужно ${item.price} монет`,
status: 'error', status: 'error',
duration: 3000, duration: 3000,
isClosable: true, isClosable: true,
@ -36,74 +25,46 @@ export const Shop: React.FC<ShopProps> = ({ items, userBalance, onPurchase }) =>
return; return;
} }
try { await onPurchase(item);
await onPurchase(item.id);
toast({
title: 'Покупка успешна!',
description: `Вы приобрели ${item.name}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Ошибка при покупке',
description: 'Произошла ошибка при совершении покупки',
status: 'error',
duration: 3000,
isClosable: true,
});
}
}; };
return ( return (
<Box p={4}> <Box w="100%">
<Text fontSize="2xl" fontWeight="bold" mb={4}> <Text fontSize="2xl" mb={4}>Магазин</Text>
Магазин <Text mb={4}>Ваш баланс: {userBalance} монет</Text>
</Text> <SimpleGrid columns={[1, 2, 3]} spacing={6}>
<Text mb={4}>
Ваш баланс: {userBalance} 🔥
</Text>
<Grid templateColumns={['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)']} gap={4}>
{items.map((item) => ( {items.map((item) => (
<Box <Box
key={item.id} key={item._id.toString()}
p={4}
borderWidth="1px" borderWidth="1px"
borderRadius="lg" borderRadius="lg"
borderColor={borderColor} overflow="hidden"
bg={bgColor} p={4}
> >
<VStack spacing={3}> {item.imageUrl && (
{item.imageUrl && ( <Image
<Image src={item.imageUrl}
src={item.imageUrl} alt={item.name}
alt={item.name} height="200px"
boxSize="100px" width="100%"
objectFit="cover" objectFit="cover"
borderRadius="md" mb={4}
/> />
)} )}
<Text fontWeight="bold">{item.name}</Text> <Text fontSize="xl" mb={2}>{item.name}</Text>
<Text fontSize="sm" color="gray.500"> <Text mb={2}>{item.description}</Text>
{item.description} <Text mb={4} color="green.500">{item.price} монет</Text>
</Text> <Button
<Text color="green.500" fontWeight="bold"> colorScheme="blue"
{item.price} 🔥 onClick={() => handlePurchase(item)}
</Text> isDisabled={userBalance < item.price}
<Button w="100%"
colorScheme="blue" >
width="full" Купить
onClick={() => handlePurchase(item)} </Button>
isDisabled={userBalance < item.price}
>
Купить
</Button>
</VStack>
</Box> </Box>
))} ))}
</Grid> </SimpleGrid>
</Box> </Box>
); );
}; }

39
app/utils/demo.ts Normal file
View File

@ -0,0 +1,39 @@
// Демо данные для тестирования без Telegram
const demoUser = {
id: 'demo_user',
first_name: 'Demo',
username: 'demo_user',
language_code: 'ru'
};
export const isDemoMode = () => {
return typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('demo');
};
export const getDemoWebApp = () => {
return {
initData: 'demo_mode',
initDataUnsafe: {
user: demoUser,
start_param: 'demo'
},
platform: 'demo',
colorScheme: 'light',
themeParams: {
bg_color: '#ffffff',
text_color: '#000000',
hint_color: '#999999',
link_color: '#2481cc',
button_color: '#2481cc',
button_text_color: '#ffffff'
},
isExpanded: true,
viewportHeight: window.innerHeight,
viewportStableHeight: window.innerHeight,
headerColor: '#ffffff',
backgroundColor: '#ffffff',
ready: () => {},
expand: () => {},
close: () => {}
};
};

View File

@ -8,11 +8,11 @@ export interface IAchievement {
} }
export interface IInventoryItem { export interface IInventoryItem {
itemId: string; itemId: mongoose.Types.ObjectId;
name: string; name: string;
description: string; description: string;
quantity: number; quantity: number;
imageUrl?: string; imageUrl: string;
} }
export interface IUser extends Document { export interface IUser extends Document {
@ -23,6 +23,7 @@ export interface IUser extends Document {
balance: number; balance: number;
achievements: IAchievement[]; achievements: IAchievement[];
inventory: IInventoryItem[]; inventory: IInventoryItem[];
addAchievement: (achievement: IAchievement) => Promise<void>;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -40,7 +41,7 @@ const UserSchema: Schema = new Schema({
dateUnlocked: { type: Date, default: Date.now } dateUnlocked: { type: Date, default: Date.now }
}], }],
inventory: [{ inventory: [{
itemId: String, itemId: { type: Schema.Types.ObjectId, ref: 'ShopItem' },
name: String, name: String,
description: String, description: String,
quantity: Number, quantity: Number,
@ -74,9 +75,7 @@ UserSchema.methods.addAchievement = async function(achievement: IAchievement) {
// Награда за достижение // Награда за достижение
this.balance += 50; this.balance += 50;
await this.save(); await this.save();
return true;
} }
return false;
}; };
export default mongoose.model<IUser>('User', UserSchema); export default mongoose.model<IUser>('User', UserSchema);