Reference
Клиентское приложение
Frontend приложение Maniton
Клиентское приложение
Обзор
Клиентское приложение Maniton — это Next.js приложение для взаимодействия с платформой Maniton. Оно предоставляет пользователю интерфейс для управления кошельком, торговли ЦФА, пополнения и вывода средств.
Технологический стек
- Framework: Next.js (App Router)
- Language: TypeScript
- Styling: Tailwind CSS
- Components: shadcn/ui
- State Management: React Context, Zustand
- HTTP Client: fetch, axios
- Forms: React Hook Form
- Validation: Zod
- Authentication: JWT tokens
- Real-time: WebSocket
Структура проекта
apps/client/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Auth routes
│ │ │ ├── login/
│ │ │ └── register/
│ │ ├── (dashboard)/ # Dashboard routes
│ │ │ ├── wallet/
│ │ │ ├── trading/
│ │ │ └── settings/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui components
│ │ ├── wallet/
│ │ ├── trading/
│ │ └── common/
│ ├── lib/ # Utilities
│ │ ├── api/
│ │ ├── auth/
│ │ ├── utils/
│ │ └── hooks/
│ └── styles/
├── public/
├── package.json
└── next.config.tsAPI Клиент
HTTP Client
// lib/api/client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Redirect to login
window.location.href = '/login';
}
return Promise.reject(error);
}
);API Endpoints
// lib/api/endpoints.ts
export const endpoints = {
auth: {
login: '/api/auth/login',
register: '/api/auth/register',
logout: '/api/auth/logout',
refresh: '/api/auth/refresh',
},
wallet: {
balance: '/api/wallet/balance',
deposit: '/api/wallet/deposit',
withdraw: '/api/wallet/withdraw',
transactions: '/api/wallet/transactions',
},
trading: {
orderBook: '/api/trading/orderbook',
placeOrder: '/api/trading/orders',
cancelOrder: '/api/trading/orders/:id',
orders: '/api/trading/orders',
trades: '/api/trading/trades',
},
cfa: {
instruments: '/api/cfa/instruments',
issue: '/api/cfa/issue',
redeem: '/api/cfa/redeem',
},
};Аутентификация
Login
// lib/auth/login.ts
export async function login(email: string, password: string) {
const response = await apiClient.post(endpoints.auth.login, {
email,
password,
});
const { token, refreshToken, user } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('user', JSON.stringify(user));
return user;
}Logout
// lib/auth/logout.ts
export function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
window.location.href = '/login';
}Auth Hook
// lib/hooks/useAuth.ts
import { createContext, useContext, useEffect, useState } from 'react';
interface AuthContextType {
user: any;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
}, []);
const login = async (email: string, password: string) => {
const user = await login(email, password);
const token = localStorage.getItem('token');
setUser(user);
setToken(token);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
setUser(null);
setToken(null);
window.location.href = '/login';
};
return (
<AuthContext.Provider value={{ user, token, login, logout, isAuthenticated: !!token }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}Кошелек
Balance
// lib/api/wallet.ts
export async function getBalance() {
const response = await apiClient.get(endpoints.wallet.balance);
return response.data;
}Deposit
export async function createDeposit(amount: number) {
const response = await apiClient.post(endpoints.wallet.deposit, { amount });
return response.data;
}Withdraw
export async function createWithdrawal(amount: number, bankAccount: string) {
const response = await apiClient.post(endpoints.wallet.withdraw, {
amount,
bankAccount,
});
return response.data;
}Торговля
Order Book
// lib/api/trading.ts
export async function getOrderBook(instrumentId: string) {
const response = await apiClient.get(endpoints.trading.orderBook, {
params: { instrument_id: instrumentId },
});
return response.data;
}Place Order
export async function placeOrder(order: {
instrumentId: string;
side: 'BUY' | 'SELL';
type: 'LIMIT' | 'MARKET';
price?: number;
quantity: number;
}) {
const response = await apiClient.post(endpoints.trading.placeOrder, order);
return response.data;
}Cancel Order
export async function cancelOrder(orderId: string) {
const response = await apiClient.delete(
endpoints.trading.cancelOrder.replace(':id', orderId)
);
return response.data;
}WebSocket
// lib/api/websocket.ts
export function connectToOrderBook(instrumentId: string, onUpdate: (data: any) => void) {
const ws = new WebSocket(
`${process.env.NEXT_PUBLIC_WS_URL}/orderbook?instrument_id=${instrumentId}`
);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
onUpdate(data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket closed');
};
return ws;
}Компоненты
Wallet Balance
// components/wallet/Balance.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { getBalance } from '@/lib/api/wallet';
export function WalletBalance() {
const { data: balance, isLoading } = useQuery({
queryKey: ['wallet', 'balance'],
queryFn: getBalance,
});
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold mb-2">Баланс</h2>
<div className="text-3xl font-bold">
{balance?.amount} {balance?.currencyCode}
</div>
</div>
);
}Order Book
// components/trading/OrderBook.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { getOrderBook } from '@/lib/api/trading';
import { connectToOrderBook } from '@/lib/api/websocket';
import { useEffect, useState } from 'react';
export function OrderBook({ instrumentId }: { instrumentId: string }) {
const { data: orderBook, isLoading } = useQuery({
queryKey: ['trading', 'orderbook', instrumentId],
queryFn: () => getOrderBook(instrumentId),
});
const [snapshot, setSnapshot] = useState(orderBook);
useEffect(() => {
const ws = connectToOrderBook(instrumentId, (data) => {
setSnapshot(data);
});
return () => {
ws.close();
};
}, [instrumentId]);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Стакан</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">Покупка</h3>
{snapshot?.bids.map((bid: any, i: number) => (
<div key={i} className="flex justify-between py-1">
<span>{bid.price}</span>
<span>{bid.quantity}</span>
</div>
))}
</div>
<div>
<h3 className="font-semibold mb-2">Продажа</h3>
{snapshot?.asks.map((ask: any, i: number) => (
<div key={i} className="flex justify-between py-1">
<span>{ask.price}</span>
<span>{ask.quantity}</span>
</div>
))}
</div>
</div>
</div>
);
}Place Order Form
// components/trading/PlaceOrderForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { placeOrder } from '@/lib/api/trading';
import { useAuth } from '@/lib/hooks/useAuth';
const orderSchema = z.object({
instrumentId: z.string(),
side: z.enum(['BUY', 'SELL']),
type: z.enum(['LIMIT', 'MARKET']),
price: z.number().optional(),
quantity: z.number().positive(),
});
type OrderForm = z.infer<typeof orderSchema>;
export function PlaceOrderForm({ instrumentId }: { instrumentId: string }) {
const { token } = useAuth();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<OrderForm>({
resolver: zodResolver(orderSchema),
defaultValues: {
instrumentId,
side: 'BUY',
type: 'LIMIT',
},
});
const onSubmit = async (data: OrderForm) => {
try {
const order = await placeOrder(data);
console.log('Order placed:', order);
} catch (error) {
console.error('Failed to place order:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Сторона</label>
<select {...register('side')} className="w-full p-2 border rounded">
<option value="BUY">Покупка</option>
<option value="SELL">Продажа</option>
</select>
{errors.side && <p className="text-red-500 text-sm">{errors.side.message}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Тип</label>
<select {...register('type')} className="w-full p-2 border rounded">
<option value="LIMIT">Лимитный</option>
<option value="MARKET">Рыночный</option>
</select>
{errors.type && <p className="text-red-500 text-sm">{errors.type.message}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Цена</label>
<input
type="number"
step="0.01"
{...register('price')}
className="w-full p-2 border rounded"
/>
{errors.price && <p className="text-red-500 text-sm">{errors.price.message}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Количество</label>
<input
type="number"
step="0.01"
{...register('quantity')}
className="w-full p-2 border rounded"
/>
{errors.quantity && <p className="text-red-500 text-sm">{errors.quantity.message}</p>}
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
>
Разместить ордер
</button>
</form>
);
}Страницы
Login Page
// app/(auth)/login/page.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { login } from '@/lib/auth/login';
import { useRouter } from 'next/navigation';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
try {
await login(data.email, data.password);
router.push('/dashboard');
} catch (error) {
console.error('Login failed:', error);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-96">
<h1 className="text-2xl font-bold mb-6">Вход</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
{...register('email')}
className="w-full p-2 border rounded"
/>
{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Пароль</label>
<input
type="password"
{...register('password')}
className="w-full p-2 border rounded"
/>
{errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
>
Войти
</button>
</form>
</div>
</div>
);
}Dashboard
// app/(dashboard)/page.tsx
'use client';
import { useAuth } from '@/lib/hooks/useAuth';
import { WalletBalance } from '@/components/wallet/Balance';
import { redirect } from 'next/navigation';
export default function DashboardPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
redirect('/login');
}
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-8">Панель управления</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<WalletBalance />
</div>
</div>
);
}Мониторинг
Метрики
// lib/analytics.ts
export const analytics = {
trackEvent: (event: string, properties?: Record<string, any>) => {
console.log('Track event:', event, properties);
// Send to analytics service
},
trackPageView: (page: string) => {
console.log('Page view:', page);
// Send to analytics service
},
trackError: (error: Error, context?: Record<string, any>) => {
console.error('Error:', error, context);
// Send to error tracking service
},
};Логи
// lib/logger.ts
export const logger = {
info: (message: string, context?: Record<string, any>) => {
console.log(`[INFO] ${message}`, context);
},
error: (message: string, context?: Record<string, any>) => {
console.error(`[ERROR] ${message}`, context);
},
warn: (message: string, context?: Record<string, any>) => {
console.warn(`[WARN] ${message}`, context);
},
};Troubleshooting
Проблема: API не отвечает
Решение:
- Проверьте, что
NEXT_PUBLIC_API_URLнастроен правильно - Проверьте, что API сервис доступен
- Проверьте CORS настройки
Проблема: WebSocket не подключается
Решение:
- Проверьте, что
NEXT_PUBLIC_WS_URLнастроен правильно - Проверьте, что WebSocket endpoint доступен
- Проверьте логи ошибок в консоли