Манитон Docs
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.ts

API Клиент

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 не отвечает

Решение:

  1. Проверьте, что NEXT_PUBLIC_API_URL настроен правильно
  2. Проверьте, что API сервис доступен
  3. Проверьте CORS настройки

Проблема: WebSocket не подключается

Решение:

  1. Проверьте, что NEXT_PUBLIC_WS_URL настроен правильно
  2. Проверьте, что WebSocket endpoint доступен
  3. Проверьте логи ошибок в консоли

Дополнительные ресурсы

On this page