Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
768693f859 | |||
5f06f9b373 | |||
015d3eb5d1 | |||
27280c831a | |||
f7d4ae4741 | |||
e3aed97b22 | |||
3a19a84962 | |||
![]() |
8ad31ec05c | ||
6fbe286ca2 | |||
65a143ef4d | |||
33248392ff | |||
0e304a8b2e | |||
8af9c21cb1 | |||
8c152a47bd | |||
4a910fc576 | |||
aa7c6300a4 | |||
54516a66e9 | |||
c4328b7698 | |||
2af270e3d8 | |||
![]() |
2b86322b75 | ||
![]() |
afae5b4506 | ||
66ff498fa0 | |||
![]() |
5a252f6f29 | ||
![]() |
54011588e7 | ||
22cc9ef144 | |||
424d18f714 |
23
.env
Normal file
23
.env
Normal 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
14
.env.example
Normal 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
21
.env.production
Normal 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
5
.gitignore
vendored
@ -1,2 +1,7 @@
|
||||
.vscode/settings.json
|
||||
/node_modules
|
||||
/.next
|
||||
/.next/static
|
||||
/.next/cache
|
||||
/.next/server
|
||||
/.next
|
||||
|
20
.next/app-build-manifest.json
Normal file
20
.next/app-build-manifest.json
Normal 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
19
.next/build-manifest.json
Normal 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": []
|
||||
}
|
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/1.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/client-development/index.pack.gz.old
vendored
Normal file
BIN
.next/cache/webpack/client-development/index.pack.gz.old
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
Normal file
BIN
.next/cache/webpack/server-development/index.pack.gz
vendored
Normal file
Binary file not shown.
1
.next/package.json
Normal file
1
.next/package.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "commonjs"}
|
1
.next/react-loadable-manifest.json
Normal file
1
.next/react-loadable-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
4
.next/server/app-paths-manifest.json
Normal file
4
.next/server/app-paths-manifest.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"/not-found": "app/not-found.js",
|
||||
"/page": "app/page.js"
|
||||
}
|
1
.next/server/middleware-build-manifest.js
Normal file
1
.next/server/middleware-build-manifest.js
Normal 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":[]}
|
6
.next/server/middleware-manifest.json
Normal file
6
.next/server/middleware-manifest.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"sortedMiddleware": [],
|
||||
"middleware": {},
|
||||
"functions": {},
|
||||
"version": 2
|
||||
}
|
1
.next/server/middleware-react-loadable-manifest.js
Normal file
1
.next/server/middleware-react-loadable-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
1
.next/server/next-font-manifest.js
Normal file
1
.next/server/next-font-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
1
.next/server/next-font-manifest.json
Normal file
1
.next/server/next-font-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
1
.next/server/pages-manifest.json
Normal file
1
.next/server/pages-manifest.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
1
.next/server/server-reference-manifest.js
Normal file
1
.next/server/server-reference-manifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {}\n}"
|
4
.next/server/server-reference-manifest.json
Normal file
4
.next/server/server-reference-manifest.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {}
|
||||
}
|
1
.next/static/chunks/polyfills.js
Normal file
1
.next/static/chunks/polyfills.js
Normal file
File diff suppressed because one or more lines are too long
1
.next/static/development/_buildManifest.js
Normal file
1
.next/static/development/_buildManifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
1
.next/static/development/_ssgManifest.js
Normal file
1
.next/static/development/_ssgManifest.js
Normal file
@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
1
.next/types/package.json
Normal file
1
.next/types/package.json
Normal file
@ -0,0 +1 @@
|
||||
{"type": "module"}
|
49
app/api/user.ts
Normal file
49
app/api/user.ts
Normal 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' });
|
||||
}
|
||||
}
|
22
app/components/ClientOnly.tsx
Normal file
22
app/components/ClientOnly.tsx
Normal 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}</>;
|
||||
}
|
17
app/components/ClientPage.tsx
Normal file
17
app/components/ClientPage.tsx
Normal 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 />;
|
||||
}
|
47
app/components/MainApp.tsx
Normal file
47
app/components/MainApp.tsx
Normal 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
70
app/components/Shop.tsx
Normal 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>
|
||||
);
|
||||
}
|
87
app/components/TransferBalance.tsx
Normal file
87
app/components/TransferBalance.tsx
Normal 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>
|
||||
);
|
||||
}
|
57
app/components/UserProfile.tsx
Normal file
57
app/components/UserProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
79
app/hooks/useTelegramWebApp.ts
Normal file
79
app/hooks/useTelegramWebApp.ts
Normal 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
23
app/layout.tsx
Normal 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
67
app/models/User.ts
Normal 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
5
app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ClientPage from './components/ClientPage';
|
||||
|
||||
export default function Home() {
|
||||
return <ClientPage />;
|
||||
}
|
123
app/pages/index.tsx
Normal file
123
app/pages/index.tsx
Normal 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
25
app/providers.tsx
Normal 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
24
app/types/user.ts
Normal 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
120
app/utils/api.ts
Normal 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
38
app/utils/db.ts
Normal 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
116
app/utils/demo.ts
Normal 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
67
app/utils/telegram.ts
Normal 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;
|
||||
}
|
||||
}
|
30
backend/models/ShopItem.ts
Normal file
30
backend/models/ShopItem.ts
Normal 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
81
backend/models/User.ts
Normal 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
207
backend/server.ts
Normal 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
16
env.d.ts
vendored
Normal 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
5
next-env.d.ts
vendored
Normal 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
48
next.config.js
Normal 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
6659
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@ -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
35
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user