Compare commits

...

26 Commits
main ... next

Author SHA1 Message Date
768693f859 prod maint 2025-03-16 13:35:56 +03:00
5f06f9b373 Ignore Errors 2025-03-16 13:29:37 +03:00
015d3eb5d1 ChakraUI Added 2025-03-16 13:25:30 +03:00
27280c831a env config 2025-03-16 13:22:40 +03:00
f7d4ae4741 DynamicAppWrapper included 2025-03-16 13:11:08 +03:00
e3aed97b22 Merge branch 'next' of https://git.campfiregg.ru/degradin/CampFireID into next 2025-03-16 13:08:00 +03:00
3a19a84962 client reqs 2025-03-16 13:07:55 +03:00
Degradin
8ad31ec05c next env 2025-03-16 13:07:26 +03:00
6fbe286ca2 react update 2025-03-16 13:03:09 +03:00
65a143ef4d tg fixes 2025-03-16 12:55:51 +03:00
33248392ff Massive fixes 2025-03-16 12:47:27 +03:00
0e304a8b2e init param
Добавление проверки isInitialized при инициализации приложения
Проверку наличия данных пользователя в initDataUnsafe
Вызов webApp.ready() после успешной инициализации
Улучшенную обработку ошибок и состояний загрузки
2025-03-16 12:39:38 +03:00
8af9c21cb1 add components
Безопасная работа с Telegram Web App SDK
Улучшенная обработка ошибок
Плавная загрузка с индикаторами
Корректная работа в демо-режиме
2025-03-16 12:35:18 +03:00
8c152a47bd twa add 2025-03-16 12:31:11 +03:00
4a910fc576 gitignore ./next 2025-03-16 12:27:24 +03:00
aa7c6300a4 minor fixes & rework to layouts 2025-03-16 12:25:12 +03:00
54516a66e9 demo splitted
Исправлены экспорты компонентов:
UserProfile теперь использует export default
Shop теперь использует export default
TransferBalance теперь использует export default
Обновлены импорты в MainApp:
Все компоненты теперь импортируются как дефолтные импорты
Исправлены типы для безопасной работы с mongoose документами
Улучшен UI компонентов:
Добавлены отступы и границы
Улучшена читаемость текста
Добавлены информативные сообщения
Исправлена обработка ошибок:
Добавлены понятные сообщения об ошибках
Улучшена валидация форм
2025-03-16 12:19:42 +03:00
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
Degradin
2b86322b75 gitignore 2025-03-16 12:00:04 +03:00
Degradin
afae5b4506 env & gitignore 2025-03-16 11:58:19 +03:00
66ff498fa0 up 2025-03-16 11:53:27 +03:00
Degradin
5a252f6f29 Merge branch 'next' of https://git.campfiregg.ru/CampFire/CampFireID into next 2025-03-16 11:43:25 +03:00
Degradin
54011588e7 gitignore 2025-03-16 11:41:44 +03:00
22cc9ef144 SSR Fixes
Добавили динамический импорт Telegram Web App SDK с помощью import(), чтобы он загружался только на клиенте
Добавили состояние загрузки и компонент Spinner для лучшего UX
Исправили типы в компонентах:
Используем IShopItem вместо собственного интерфейса ShopItem
Создали тип SafeUser, который исключает свойства mongoose Document из типа пользователя
Добавили безопасную проверку на наличие пользователя в данных Telegram WebApp
2025-03-16 11:37:54 +03:00
424d18f714 NextJS update 2025-03-16 11:25:09 +03:00
54 changed files with 8264 additions and 38 deletions

23
.env Normal file
View File

@ -0,0 +1,23 @@
# Настройки Next.js
NEXT_PUBLIC_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Настройки сервера
PORT=3001
NODE_ENV=development
# База данных
MONGODB_URI=mongodb://localhost:27017/campfire-id
# Безопасность
JWT_SECRET=your-super-secret-key-change-in-production
# Telegram WebApp
NEXT_PUBLIC_BOT_USERNAME=CampFireIDBot
NEXT_PUBLIC_BOT_TOKEN=7641998551:AAF_2-av1rUy_-icQ1fsfHOyYBGFt8cEmE4
NEXT_PUBLIC_WEBAPP_URL=https://t.me/CampFireIDBot/app
# Telegram Bot
TELEGRAM_BOT_TOKEN=7641998551:AAF_2-av1rUy_-icQ1fsfHOyYBGFt8cEmE4
TELEGRAM_APP_ID=your_app_id_here
TELEGRAM_APP_HASH=your_app_hash_here

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Telegram Bot
TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_APP_ID=your_app_id_here
TELEGRAM_APP_HASH=your_app_hash_here
# MongoDB
MONGODB_URI=mongodb://localhost:27017/campfire_id
# JWT
JWT_SECRET=your_jwt_secret_here
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
NODE_ENV=development

21
.env.production Normal file
View File

@ -0,0 +1,21 @@
# Настройки Next.js
NEXT_PUBLIC_URL=https://kubek.campfiregg.ru
NEXT_PUBLIC_API_URL=https://kubek.campfiregg.ru/api
# Настройки сервера
PORT=3000
NODE_ENV=production
# База данных
MONGODB_URI=mongodb://localhost:27017/campfire-id
# Безопасность
JWT_SECRET=your-super-secret-key-change-in-production
# Telegram WebApp
NEXT_PUBLIC_BOT_USERNAME=CampFireIDBot
NEXT_PUBLIC_BOT_TOKEN=7641998551:AAF_2-av1rUy_-icQ1fsfHOyYBGFt8cEmE4
NEXT_PUBLIC_WEBAPP_URL=https://t.me/CampFireIDBot/app
# Telegram Bot
TELEGRAM_BOT_TOKEN=7641998551:AAF_2-av1rUy_-icQ1fsfHOyYBGFt8cEmE4

5
.gitignore vendored
View File

@ -1,2 +1,7 @@
.vscode/settings.json
/node_modules
/.next
/.next/static
/.next/cache
/.next/server
/.next

View File

@ -0,0 +1,20 @@
{
"pages": {
"/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/page.js"
],
"/not-found": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/not-found.js"
]
}
}

19
.next/build-manifest.json Normal file
View File

@ -0,0 +1,19 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [
"static/chunks/webpack.js",
"static/chunks/main-app.js"
],
"pages": {
"/_app": []
},
"ampFirstPages": []
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
.next/package.json Normal file
View File

@ -0,0 +1 @@
{"type": "commonjs"}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,4 @@
{
"/not-found": "app/not-found.js",
"/page": "app/page.js"
}

View File

@ -0,0 +1 @@
self.__BUILD_MANIFEST={"polyfillFiles":["static/chunks/polyfills.js"],"devFiles":[],"ampDevFiles":[],"lowPriorityFiles":["static/development/_buildManifest.js","static/development/_ssgManifest.js"],"rootMainFiles":["static/chunks/webpack.js","static/chunks/main-app.js"],"pages":{"/_app":[]},"ampFirstPages":[]}

View File

@ -0,0 +1,6 @@
{
"sortedMiddleware": [],
"middleware": {},
"functions": {},
"version": 2
}

View File

@ -0,0 +1 @@
self.__REACT_LOADABLE_MANIFEST="{}"

View File

@ -0,0 +1 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"

View File

@ -0,0 +1 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {}\n}"

View File

@ -0,0 +1,4 @@
{
"node": {},
"edge": {}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()

View File

@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

1
.next/types/package.json Normal file
View File

@ -0,0 +1 @@
{"type": "module"}

49
app/api/user.ts Normal file
View File

@ -0,0 +1,49 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { connectDB } from '../utils/db';
import User from '../models/User';
import { verifyTelegramWebAppData } from '../utils/telegram';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
// Проверяем данные Telegram WebApp
const initData = req.headers['x-telegram-init-data'] as string;
if (!initData) {
return res.status(401).json({ message: 'Unauthorized' });
}
const telegramData = await verifyTelegramWebAppData(initData);
if (!telegramData) {
return res.status(401).json({ message: 'Invalid Telegram data' });
}
// Подключаемся к базе данных
await connectDB();
// Получаем или создаем пользователя
let user = await User.findOne({ telegramId: telegramData.user.id });
if (!user) {
user = await User.create({
telegramId: telegramData.user.id,
username: telegramData.user.username || `user${telegramData.user.id}`,
level: 1,
experience: 0,
balance: 1000, // Начальный баланс для новых пользователей
achievements: [],
inventory: [],
});
}
res.status(200).json(user);
} catch (error) {
console.error('Error in user API:', error);
res.status(500).json({ message: 'Internal server error' });
}
}

View File

@ -0,0 +1,22 @@
'use client';
import { useEffect, useState } from 'react';
import { Center, Spinner } from '@chakra-ui/react';
export default function ClientOnly({ children }: { children: React.ReactNode }) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return (
<Center h="100vh">
<Spinner size="xl" />
</Center>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,17 @@
'use client';
import dynamic from 'next/dynamic';
import { Center, Spinner } from '@chakra-ui/react';
const MainApp = dynamic(() => import('./MainApp'), {
ssr: false,
loading: () => (
<Center h="100vh">
<Spinner size="xl" />
</Center>
),
});
export default function ClientPage() {
return <MainApp />;
}

View File

@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { Center, Spinner, Text, VStack } from '@chakra-ui/react';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
export default function MainApp() {
const [isMounted, setIsMounted] = useState(false);
const { webApp, error, isLoading } = useTelegramWebApp();
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted || isLoading) {
return (
<Center h="100vh">
<Spinner size="xl" />
</Center>
);
}
if (error) {
return (
<Center h="100vh">
<VStack>
<Text color="red.500">Ошибка инициализации</Text>
<Text>{error.message}</Text>
</VStack>
</Center>
);
}
if (!webApp) {
return (
<Center h="100vh">
<Text>WebApp не инициализирован</Text>
</Center>
);
}
return (
<Center h="100vh">
<Text>Добро пожаловать, {webApp.initDataUnsafe.user?.first_name}!</Text>
</Center>
);
}

70
app/components/Shop.tsx Normal file
View File

@ -0,0 +1,70 @@
'use client';
import React from 'react';
import { Box, SimpleGrid, Button, Text, Image, useToast } from '@chakra-ui/react';
import { IShopItem } from '../../backend/models/ShopItem';
interface ShopProps {
items: IShopItem[];
userBalance: number;
onPurchase: (item: IShopItem) => Promise<void>;
}
export default function Shop({ items, userBalance, onPurchase }: ShopProps) {
const toast = useToast();
const handlePurchase = async (item: IShopItem) => {
if (userBalance < item.price) {
toast({
title: 'Недостаточно средств',
description: `Для покупки ${item.name} нужно ${item.price} монет`,
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
await onPurchase(item);
};
return (
<Box w="100%">
<Text fontSize="2xl" mb={4}>Магазин</Text>
<Text mb={4}>Ваш баланс: {userBalance} монет</Text>
<SimpleGrid columns={[1, 2, 3]} spacing={6}>
{items.map((item) => (
<Box
key={item._id.toString()}
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
p={4}
>
{item.imageUrl && (
<Image
src={item.imageUrl}
alt={item.name}
height="200px"
width="100%"
objectFit="cover"
mb={4}
/>
)}
<Text fontSize="xl" mb={2}>{item.name}</Text>
<Text mb={2}>{item.description}</Text>
<Text mb={4} color="green.500">{item.price} монет</Text>
<Button
colorScheme="blue"
onClick={() => handlePurchase(item)}
isDisabled={userBalance < item.price}
w="100%"
>
Купить
</Button>
</Box>
))}
</SimpleGrid>
</Box>
);
}

View File

@ -0,0 +1,87 @@
'use client';
import React, { useState } from 'react';
import {
Box,
VStack,
Text,
Input,
Button,
FormControl,
FormLabel,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
} from '@chakra-ui/react';
interface TransferBalanceProps {
userBalance: number;
onTransfer: (username: string, amount: number) => Promise<void>;
}
export default function TransferBalance({ userBalance, onTransfer }: TransferBalanceProps) {
const [username, setUsername] = useState('');
const [amount, setAmount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const handleTransfer = async () => {
if (!username || amount <= 0 || amount > userBalance) {
return;
}
setIsLoading(true);
try {
await onTransfer(username, amount);
// Очищаем форму после успешного перевода
setUsername('');
setAmount(0);
} finally {
setIsLoading(false);
}
};
return (
<Box w="100%" p={4} borderWidth="1px" borderRadius="lg">
<VStack spacing={4} align="stretch">
<Text fontSize="2xl">Перевод монет</Text>
<Text>Ваш баланс: {userBalance} монет</Text>
<FormControl>
<FormLabel>Имя пользователя получателя</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Введите имя пользователя"
/>
</FormControl>
<FormControl>
<FormLabel>Сумма перевода</FormLabel>
<NumberInput
value={amount}
onChange={(_, value) => setAmount(value)}
min={1}
max={userBalance}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
<Button
colorScheme="blue"
onClick={handleTransfer}
isLoading={isLoading}
isDisabled={!username || amount <= 0 || amount > userBalance}
>
Перевести
</Button>
</VStack>
</Box>
);
}

View File

@ -0,0 +1,57 @@
'use client';
import React from 'react';
import { Box, VStack, Text, Progress, SimpleGrid, Badge } from '@chakra-ui/react';
import { IAchievement } from '../../backend/models/User';
interface UserProfileProps {
username: string;
level: number;
experience: number;
balance: number;
achievements: IAchievement[];
}
export default function UserProfile({ username, level, experience, balance, achievements }: UserProfileProps) {
// Простая формула для определения прогресса опыта
const expNeeded = level * 100;
const expProgress = (experience / expNeeded) * 100;
return (
<Box w="100%" p={4} borderWidth="1px" borderRadius="lg">
<VStack spacing={4} align="stretch">
<Text fontSize="2xl">Профиль</Text>
<Box>
<Text fontSize="lg">Имя пользователя: {username}</Text>
<Text>Уровень: {level}</Text>
<Text mb={2}>Опыт: {experience}/{expNeeded}</Text>
<Progress value={expProgress} colorScheme="green" />
</Box>
<Box>
<Text fontSize="lg">Баланс: {balance} монет</Text>
</Box>
{achievements.length > 0 && (
<Box>
<Text fontSize="lg" mb={2}>Достижения:</Text>
<SimpleGrid columns={[1, 2, 3]} spacing={4}>
{achievements.map((achievement) => (
<Badge
key={achievement.id}
p={2}
borderRadius="md"
colorScheme="purple"
variant="subtle"
>
{achievement.name}
</Badge>
))}
</SimpleGrid>
</Box>
)}
</VStack>
</Box>
);
}

View File

@ -0,0 +1,79 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { WebApp } from '@twa-dev/types';
import { isDemoMode, getDemoWebApp } from '../utils/demo';
declare global {
interface Window {
Telegram?: {
WebApp?: WebApp;
};
}
}
export interface UseTelegramWebAppResult {
webApp: WebApp | null;
error: Error | null;
isLoading: boolean;
isInitialized: boolean;
}
export function useTelegramWebApp(): UseTelegramWebAppResult {
const [webApp, setWebApp] = useState<WebApp | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const initWebApp = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
if (isDemoMode()) {
const demoWebApp = getDemoWebApp();
setWebApp(demoWebApp);
setIsInitialized(true);
return;
}
if (typeof window !== 'undefined') {
if (window.Telegram?.WebApp) {
setWebApp(window.Telegram.WebApp);
setIsInitialized(true);
} else {
throw new Error('Telegram WebApp не найден');
}
}
} catch (err) {
console.error('Ошибка инициализации WebApp:', err);
setError(err instanceof Error ? err : new Error('Неизвестная ошибка'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
initWebApp();
}, [initWebApp]);
useEffect(() => {
if (!webApp) return;
const handleThemeChange = () => {
// Обновляем состояние при изменении темы
setWebApp(prevWebApp => {
if (!prevWebApp) return null;
return { ...prevWebApp };
});
};
webApp.onEvent('themeChanged', handleThemeChange);
return () => {
webApp.offEvent('themeChanged', handleThemeChange);
};
}, [webApp]);
return { webApp, error, isLoading, isInitialized };
}

23
app/layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { Providers } from './providers';
export const metadata: Metadata = {
title: 'CampFire ID',
description: 'Telegram Mini App for user achievements and virtual economy',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

67
app/models/User.ts Normal file
View File

@ -0,0 +1,67 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IUser extends Document {
telegramId: number;
username: string;
level: number;
experience: number;
balance: number;
achievements: Array<{
id: string;
name: string;
description: string;
unlockedAt: Date;
}>;
inventory: Array<{
itemId: string;
quantity: number;
}>;
createdAt: Date;
updatedAt: Date;
}
const UserSchema: Schema = new Schema({
telegramId: {
type: Number,
required: true,
unique: true,
},
username: {
type: String,
required: true,
},
level: {
type: Number,
default: 1,
},
experience: {
type: Number,
default: 0,
},
balance: {
type: Number,
default: 0,
},
achievements: [{
id: String,
name: String,
description: String,
unlockedAt: {
type: Date,
default: Date.now,
},
}],
inventory: [{
itemId: String,
quantity: Number,
}],
}, {
timestamps: true,
});
// Виртуальное поле для расчета следующего уровня
UserSchema.virtual('nextLevelExp').get(function() {
return Math.floor(100 * Math.pow(1.5, this.level - 1));
});
export default mongoose.models.User || mongoose.model<IUser>('User', UserSchema);

5
app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import ClientPage from './components/ClientPage';
export default function Home() {
return <ClientPage />;
}

123
app/pages/index.tsx Normal file
View File

@ -0,0 +1,123 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
useToast,
} from '@chakra-ui/react';
import { WebApp } from '@telegram-mini-apps/sdk';
import UserProfile from '../components/UserProfile';
import Shop from '../components/Shop';
import TransferBalance from '../components/TransferBalance';
import { IUser } from '../models/User';
const Home: React.FC = () => {
const [user, setUser] = useState<IUser | null>(null);
const toast = useToast();
useEffect(() => {
// Инициализация Telegram Mini App
WebApp.ready();
// Загрузка данных пользователя
fetchUserData();
}, []);
const fetchUserData = async () => {
try {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data);
} catch (error) {
toast({
title: 'Ошибка',
description: 'Не удалось загрузить данные пользователя',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const handlePurchase = async (itemId: string, price: number) => {
try {
const response = await fetch('/api/shop/purchase', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ itemId, price }),
});
if (response.ok) {
await fetchUserData(); // Обновляем данные пользователя
return true;
}
return false;
} catch (error) {
return false;
}
};
const handleTransfer = async (recipientId: string, amount: number) => {
try {
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipientId, amount }),
});
if (response.ok) {
await fetchUserData(); // Обновляем данные пользователя
return true;
}
return false;
} catch (error) {
return false;
}
};
if (!user) {
return null; // или компонент загрузки
}
return (
<Container maxW="container.md" py={4}>
<Tabs isFitted variant="enclosed">
<TabList mb="1em">
<Tab>Профиль</Tab>
<Tab>Магазин</Tab>
<Tab>Перевод</Tab>
</TabList>
<TabPanels>
<TabPanel>
<UserProfile user={user} />
</TabPanel>
<TabPanel>
<Shop
userBalance={user.balance}
onPurchase={handlePurchase}
/>
</TabPanel>
<TabPanel>
<TransferBalance
userBalance={user.balance}
onTransfer={handleTransfer}
/>
</TabPanel>
</TabPanels>
</Tabs>
</Container>
);
};
export default Home;

25
app/providers.tsx Normal file
View File

@ -0,0 +1,25 @@
'use client';
import { CacheProvider } from '@chakra-ui/next-js';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
const theme = extendTheme({
config: {
initialColorMode: 'light',
useSystemColorMode: false,
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif',
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<CacheProvider>
<ChakraProvider theme={theme}>
{children}
</ChakraProvider>
</CacheProvider>
);
}

24
app/types/user.ts Normal file
View File

@ -0,0 +1,24 @@
export interface Achievement {
id: string;
name: string;
description: string;
dateUnlocked: Date;
}
export interface User {
id: string;
username: string;
level: number;
experience: number;
balance: number;
achievements: Achievement[];
inventory: InventoryItem[];
}
export interface InventoryItem {
id: string;
name: string;
description: string;
quantity: number;
imageUrl?: string;
}

120
app/utils/api.ts Normal file
View File

@ -0,0 +1,120 @@
import axios from 'axios';
import { isDemoMode } from './demo';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Демо-данные
const demoData = {
user: {
_id: 'demo_user_id',
telegramId: '12345',
username: 'demo_user',
level: 1,
experience: 0,
balance: 1000,
achievements: [],
},
shopItems: [
{
_id: 'demo_item_1',
name: 'Демо предмет 1',
description: 'Описание демо предмета 1',
price: 100,
type: 'consumable',
},
{
_id: 'demo_item_2',
name: 'Демо предмет 2',
description: 'Описание демо предмета 2',
price: 200,
type: 'permanent',
},
],
};
// Интерцептор для добавления токена
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Интерцептор для обработки ошибок
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
}
return Promise.reject(error);
}
);
export const auth = async (telegramId: string, username: string) => {
if (isDemoMode()) {
localStorage.setItem('token', 'demo_token');
return { token: 'demo_token', user: demoData.user };
}
const response = await api.post('/auth', { telegramId, username });
const { token } = response.data;
localStorage.setItem('token', token);
return response.data;
};
export const getProfile = async () => {
if (isDemoMode()) {
return demoData.user;
}
const response = await api.get('/profile');
return response.data;
};
export const getShopItems = async () => {
if (isDemoMode()) {
return demoData.shopItems;
}
const response = await api.get('/shop');
return response.data;
};
export const purchaseItem = async (itemId: string) => {
if (isDemoMode()) {
const item = demoData.shopItems.find(item => item._id === itemId);
if (!item) {
throw new Error('Предмет не найден');
}
if (demoData.user.balance < item.price) {
throw new Error('Недостаточно средств');
}
demoData.user.balance -= item.price;
return { user: demoData.user };
}
const response = await api.post(`/shop/purchase/${itemId}`);
return response.data;
};
export const transferBalance = async (username: string, amount: number) => {
if (isDemoMode()) {
if (demoData.user.balance < amount) {
throw new Error('Недостаточно средств');
}
demoData.user.balance -= amount;
return { balance: demoData.user.balance };
}
const response = await api.post('/transfer', { username, amount });
return response.data;
};

38
app/utils/db.ts Normal file
View File

@ -0,0 +1,38 @@
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI!;
if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable');
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
export async function connectDB() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
return mongoose;
});
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.conn;
}

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

@ -0,0 +1,116 @@
'use client';
import type { WebApp, WebAppUser, WebAppInitData, Platforms, HapticFeedback } from '@twa-dev/types';
// Демо данные для тестирования без Telegram
const demoUser: WebAppUser = {
id: 12345,
first_name: 'Demo',
username: 'demo_user',
language_code: 'ru',
is_premium: false,
last_name: '',
photo_url: ''
};
const demoInitData: WebAppInitData = {
query_id: 'demo_query',
user: demoUser,
auth_date: Date.now(),
hash: 'demo_hash',
start_param: ''
};
export const isDemoMode = () => {
return typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('demo');
};
// Создаем заглушки для методов WebApp
const createNoopFunction = () => () => {};
// Создаем заглушку для HapticFeedback
const demoHapticFeedback: HapticFeedback = {
impactOccurred: (_style: "light" | "medium" | "heavy" | "rigid" | "soft") => demoHapticFeedback,
notificationOccurred: (_type: "error" | "success" | "warning") => demoHapticFeedback,
selectionChanged: () => demoHapticFeedback
};
export const getDemoWebApp = (): WebApp => ({
initData: JSON.stringify(demoInitData),
initDataUnsafe: demoInitData,
platform: 'WEBVIEW' as Platforms,
colorScheme: 'light',
themeParams: {
bg_color: '#ffffff',
text_color: '#000000',
hint_color: '#999999',
link_color: '#2481cc',
button_color: '#2481cc',
button_text_color: '#ffffff',
secondary_bg_color: '#f0f0f0'
},
isExpanded: true,
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 800,
viewportStableHeight: typeof window !== 'undefined' ? window.innerHeight : 800,
headerColor: '#ffffff',
backgroundColor: '#ffffff',
isClosingConfirmationEnabled: true,
BackButton: {
isVisible: false,
onClick: createNoopFunction(),
offClick: createNoopFunction(),
show: createNoopFunction(),
hide: createNoopFunction()
},
MainButton: {
text: '',
color: '#2481cc',
textColor: '#ffffff',
isVisible: false,
isProgressVisible: false,
isActive: true,
setText: createNoopFunction(),
onClick: createNoopFunction(),
offClick: createNoopFunction(),
show: createNoopFunction(),
hide: createNoopFunction(),
enable: createNoopFunction(),
disable: createNoopFunction(),
showProgress: createNoopFunction(),
hideProgress: createNoopFunction(),
setParams: createNoopFunction()
},
HapticFeedback: demoHapticFeedback,
CloudStorage: {
setItem: createNoopFunction(),
getItem: createNoopFunction(),
getItems: createNoopFunction(),
removeItem: createNoopFunction(),
removeItems: createNoopFunction(),
getKeys: createNoopFunction()
},
version: '7.0',
isVersionAtLeast: (version: string) => true,
setHeaderColor: createNoopFunction(),
setBackgroundColor: createNoopFunction(),
enableClosingConfirmation: createNoopFunction(),
disableClosingConfirmation: createNoopFunction(),
onEvent: createNoopFunction(),
offEvent: createNoopFunction(),
sendData: createNoopFunction(),
switchInlineQuery: createNoopFunction(),
openLink: createNoopFunction(),
openTelegramLink: createNoopFunction(),
openInvoice: createNoopFunction(),
showPopup: createNoopFunction(),
showAlert: createNoopFunction(),
showConfirm: createNoopFunction(),
showScanQrPopup: createNoopFunction(),
closeScanQrPopup: createNoopFunction(),
readTextFromClipboard: createNoopFunction(),
requestWriteAccess: createNoopFunction(),
requestContact: createNoopFunction(),
ready: createNoopFunction(),
expand: createNoopFunction(),
close: createNoopFunction(),
});

67
app/utils/telegram.ts Normal file
View File

@ -0,0 +1,67 @@
import crypto from 'crypto';
interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
}
interface TelegramInitData {
query_id?: string;
user: TelegramUser;
auth_date: number;
hash: string;
}
export async function verifyTelegramWebAppData(initData: string): Promise<TelegramInitData | null> {
try {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
if (!botToken) {
throw new Error('TELEGRAM_BOT_TOKEN is not defined');
}
// Разбираем строку initData
const searchParams = new URLSearchParams(initData);
const hash = searchParams.get('hash');
searchParams.delete('hash');
// Сортируем параметры
const params = Array.from(searchParams.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
// Создаем секретный ключ
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(botToken)
.digest();
// Вычисляем хеш
const calculatedHash = crypto
.createHmac('sha256', secretKey)
.update(params)
.digest('hex');
// Проверяем хеш
if (calculatedHash !== hash) {
return null;
}
// Парсим данные пользователя
const user: TelegramUser = JSON.parse(searchParams.get('user') || '{}');
const authDate = parseInt(searchParams.get('auth_date') || '0', 10);
return {
query_id: searchParams.get('query_id') || undefined,
user,
auth_date: authDate,
hash: hash || '',
};
} catch (error) {
console.error('Error verifying Telegram data:', error);
return null;
}
}

View File

@ -0,0 +1,30 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IShopItem extends Document {
id: string;
name: string;
description: string;
price: number;
imageUrl?: string;
available: boolean;
type: 'badge' | 'frame' | 'effect' | 'other';
createdAt: Date;
updatedAt: Date;
}
const ShopItemSchema: Schema = new Schema({
name: { type: String, required: true },
description: { type: String, required: true },
price: { type: Number, required: true },
imageUrl: { type: String },
available: { type: Boolean, default: true },
type: {
type: String,
required: true,
enum: ['badge', 'frame', 'effect', 'other']
}
}, {
timestamps: true
});
export default mongoose.model<IShopItem>('ShopItem', ShopItemSchema);

81
backend/models/User.ts Normal file
View File

@ -0,0 +1,81 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IAchievement {
id: string;
name: string;
description: string;
dateUnlocked: Date;
}
export interface IInventoryItem {
itemId: mongoose.Types.ObjectId;
name: string;
description: string;
quantity: number;
imageUrl: string;
}
export interface IUser extends Document {
telegramId: string;
username: string;
level: number;
experience: number;
balance: number;
achievements: IAchievement[];
inventory: IInventoryItem[];
addAchievement: (achievement: IAchievement) => Promise<void>;
createdAt: Date;
updatedAt: Date;
}
const UserSchema: Schema = new Schema({
telegramId: { type: String, required: true, unique: true },
username: { type: String, required: true },
level: { type: Number, default: 1 },
experience: { type: Number, default: 0 },
balance: { type: Number, default: 0 },
achievements: [{
id: String,
name: String,
description: String,
dateUnlocked: { type: Date, default: Date.now }
}],
inventory: [{
itemId: { type: Schema.Types.ObjectId, ref: 'ShopItem' },
name: String,
description: String,
quantity: Number,
imageUrl: String
}]
}, {
timestamps: true
});
// Метод для добавления опыта и повышения уровня
UserSchema.methods.addExperience = async function(amount: number) {
this.experience += amount;
// Простая формула для определения необходимого опыта для следующего уровня
const expNeeded = this.level * 100;
while (this.experience >= expNeeded) {
this.experience -= expNeeded;
this.level += 1;
// Бонус за новый уровень
this.balance += this.level * 10;
}
await this.save();
};
// Метод для добавления достижения
UserSchema.methods.addAchievement = async function(achievement: IAchievement) {
if (!this.achievements.some(a => a.id === achievement.id)) {
this.achievements.push(achievement);
// Награда за достижение
this.balance += 50;
await this.save();
}
};
export default mongoose.model<IUser>('User', UserSchema);

207
backend/server.ts Normal file
View File

@ -0,0 +1,207 @@
import express from 'express';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
import cors from 'cors';
import User, { IUser } from './models/User';
import ShopItem from './models/ShopItem';
dotenv.config();
// Расширяем типы Express
declare global {
namespace Express {
interface Request {
user?: {
userId: string;
telegramId: string;
}
}
}
}
const app = express();
// Middleware
app.use(cors({
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true
}));
app.use(express.json());
// Подключение к MongoDB
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/campfire-id')
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
// Middleware для проверки JWT
const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Требуется авторизация' });
}
jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key', (err: any, user: any) => {
if (err) {
return res.status(403).json({ error: 'Недействительный токен' });
}
req.user = user;
next();
});
};
// Обработка ошибок
const errorHandler = (err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({
error: 'Ошибка сервера',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
};
// Регистрация/авторизация пользователя
app.post('/api/auth', async (req, res, next) => {
try {
const { telegramId, username } = req.body;
if (!telegramId || !username) {
return res.status(400).json({ error: 'Отсутствуют обязательные поля' });
}
let user = await User.findOne({ telegramId });
if (!user) {
user = new User({
telegramId,
username,
// Начальный бонус для новых пользователей
balance: 100
});
await user.save();
}
const token = jwt.sign(
{ userId: user._id, telegramId },
process.env.JWT_SECRET || 'your-secret-key',
{ expiresIn: '7d' }
);
res.json({ token, user });
} catch (error) {
next(error);
}
});
// Получение профиля пользователя
app.get('/api/profile', authenticateToken, async (req, res) => {
try {
const user = await User.findById(req.user.userId);
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Получение списка предметов в магазине
app.get('/api/shop', authenticateToken, async (req, res) => {
try {
const items = await ShopItem.find({ available: true });
res.json(items);
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Покупка предмета
app.post('/api/shop/purchase', authenticateToken, async (req, res) => {
try {
const { itemId } = req.body;
const user = await User.findById(req.user.userId);
const item = await ShopItem.findById(itemId);
if (!user || !item) {
return res.status(404).json({ error: 'Пользователь или предмет не найден' });
}
if (user.balance < item.price) {
return res.status(400).json({ error: 'Недостаточно средств' });
}
// Обновляем баланс и инвентарь
user.balance -= item.price;
const existingItem = user.inventory.find(i => i.itemId === itemId);
if (existingItem) {
existingItem.quantity += 1;
} else {
user.inventory.push({
itemId: item._id,
name: item.name,
description: item.description,
quantity: 1,
imageUrl: item.imageUrl
});
}
await user.save();
res.json({ success: true, user });
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Перевод баланса между пользователями
app.post('/api/transfer', authenticateToken, async (req, res) => {
try {
const { recipientUsername, amount } = req.body;
if (amount <= 0) {
return res.status(400).json({ error: 'Неверная сумма перевода' });
}
const sender = await User.findById(req.user.userId);
const recipient = await User.findOne({ username: recipientUsername });
if (!sender || !recipient) {
return res.status(404).json({ error: 'Отправитель или получатель не найден' });
}
if (sender.balance < amount) {
return res.status(400).json({ error: 'Недостаточно средств' });
}
// Выполняем перевод
sender.balance -= amount;
recipient.balance += amount;
await sender.save();
await recipient.save();
// Добавляем достижение за первый перевод
if (sender.achievements.every(a => a.id !== 'first_transfer')) {
await sender.addAchievement({
id: 'first_transfer',
name: 'Щедрая душа',
description: 'Совершили первый перевод',
dateUnlocked: new Date()
});
}
res.json({ success: true, balance: sender.balance });
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Добавляем обработчик ошибок в конце
app.use(errorHandler);
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

16
env.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
declare namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_URL: string;
NEXT_PUBLIC_API_URL: string;
NEXT_PUBLIC_BOT_USERNAME: string;
NEXT_PUBLIC_BOT_TOKEN: string;
NEXT_PUBLIC_WEBAPP_URL: string;
PORT: string;
NODE_ENV: 'development' | 'production' | 'test';
MONGODB_URI: string;
JWT_SECRET: string;
TELEGRAM_BOT_TOKEN: string;
TELEGRAM_APP_ID: string;
TELEGRAM_APP_HASH: string;
}
}

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

48
next.config.js Normal file
View File

@ -0,0 +1,48 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: 'standalone',
experimental: {
serverActions: {
allowedOrigins: ['telegram.org', 't.me', 'kubek.campfiregg.ru'],
},
},
webpack: (config) => {
config.externals.push({
'utf-8-validate': 'commonjs utf-8-validate',
'bufferutil': 'commonjs bufferutil',
});
return config;
},
images: {
domains: ['t.me', 'telegram.org', 'kubek.campfiregg.ru'],
},
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self' telegram.org *.telegram.org t.me kubek.campfiregg.ru data: 'unsafe-inline' 'unsafe-eval';"
}
],
},
];
},
typescript: {
ignoreBuildErrors: true,
},
poweredByHeader: false,
compress: true,
productionBrowserSourceMaps: false,
basePath: '',
assetPrefix: process.env.NODE_ENV === 'production' ? 'https://kubek.campfiregg.ru' : ''
}
module.exports = nextConfig

6659
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,45 @@
{
"dependencies": {
"@faker-js/faker": "^9.5.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"faker": "^6.6.6"
},
"name": "campfireid",
"version": "0.0.1",
"description": "CampFire Identification Card (Telegram Mini APp)",
"main": "index.js",
"devDependencies": {},
"name": "campfire-id",
"version": "1.0.0",
"private": true,
"description": "Telegram Mini App for user achievements and virtual economy",
"scripts": {
"test": "node live.js"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prod:build": "NODE_ENV=production next build",
"prod:start": "NODE_ENV=production next start -p 3000"
},
"repository": {
"type": "git",
"url": "https://git.campfiregg.ru/CampFire/CampFireID"
"dependencies": {
"@chakra-ui/next-js": "^2.2.0",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@twa-dev/sdk": "^7.0.0",
"@twa-dev/types": "^7.0.0",
"@types/cors": "^2.8.17",
"axios": "^1.6.7",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"framer-motion": "^11.0.8",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.2.1",
"next": "13.5.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.22.4"
},
"author": "CampFire Solutions",
"license": "ISC"
"devDependencies": {
"@types/express": "^4.17.18",
"@types/node": "^20.11.24",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"eslint": "^8.57.0",
"eslint-config-next": "13.5.8",
"prettier": "^3.0.3",
"typescript": "^5.3.3"
}
}

35
tsconfig.json Normal file
View File

@ -0,0 +1,35 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"target": "ES2017"
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}