Initial commit: MVP structure + Cursor documentation + Onboarding components
This commit is contained in:
51
src/README.md
Normal file
51
src/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# MicroDAO Frontend - Структура проекту
|
||||
|
||||
## Структура каталогів
|
||||
|
||||
```
|
||||
src/
|
||||
api/ # API клієнти та типи
|
||||
client.ts # Базовий API клієнт
|
||||
auth.ts # Авторизація
|
||||
teams.ts # Спільноти
|
||||
channels.ts # Канали
|
||||
agents.ts # Агенти
|
||||
components/ # React компоненти
|
||||
onboarding/ # Компоненти онбордингу
|
||||
OnboardingStepper.tsx
|
||||
StepWelcome.tsx
|
||||
StepCreateTeam.tsx
|
||||
StepSelectMode.tsx
|
||||
StepCreateChannel.tsx
|
||||
StepAgentSettings.tsx
|
||||
StepInvite.tsx
|
||||
hooks/ # React hooks
|
||||
useOnboarding.ts
|
||||
pages/ # Сторінки
|
||||
OnboardingPage.tsx
|
||||
types/ # TypeScript типи
|
||||
api.ts
|
||||
```
|
||||
|
||||
## Онбординг
|
||||
|
||||
Онбординг реалізовано як багатокроковий процес з 6 кроками:
|
||||
|
||||
1. **Ласкаво просимо** - привітальний екран
|
||||
2. **Створити спільноту** - форма з назвою та описом
|
||||
3. **Режим приватності** - вибір Public/Confidential
|
||||
4. **Перший канал** - створення каналу
|
||||
5. **Агент та пам'ять** - налаштування агента
|
||||
6. **Запросити команду** - посилання-запрошення
|
||||
|
||||
## API Інтеграція
|
||||
|
||||
Всі API виклики типізовані та обробляють помилки. Базовий URL налаштовується через змінну середовища `VITE_API_URL` (за замовчуванням `https://api.microdao.xyz`).
|
||||
|
||||
## Наступні кроки
|
||||
|
||||
- Додати сторінку налаштувань (Settings)
|
||||
- Реалізувати чат інтерфейс
|
||||
- Додати публічний канал landing page
|
||||
- Інтегрувати WebSocket для real-time оновлень
|
||||
|
||||
11
src/api/agents.ts
Normal file
11
src/api/agents.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { apiGet, apiPost } from './client';
|
||||
import type { Agent, CreateAgentRequest } from '../types/api';
|
||||
|
||||
export async function getAgents(): Promise<{ agents: Agent[] }> {
|
||||
return apiGet<{ agents: Agent[] }>('/agents');
|
||||
}
|
||||
|
||||
export async function createAgent(data: CreateAgentRequest): Promise<Agent> {
|
||||
return apiPost<Agent>('/agents', data);
|
||||
}
|
||||
|
||||
16
src/api/auth.ts
Normal file
16
src/api/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { apiPost } from './client';
|
||||
import type { AuthResponse, LoginEmailRequest, ExchangeCodeRequest } from '../types/api';
|
||||
|
||||
export async function loginEmail(data: LoginEmailRequest): Promise<{ success: boolean; message: string }> {
|
||||
return apiPost<{ success: boolean; message: string }>('/auth/login-email', data);
|
||||
}
|
||||
|
||||
export async function exchangeCode(data: ExchangeCodeRequest): Promise<AuthResponse> {
|
||||
const response = await apiPost<AuthResponse>('/auth/exchange', data);
|
||||
// Зберігаємо токен
|
||||
if (response.token) {
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
28
src/api/channels.ts
Normal file
28
src/api/channels.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { apiGet, apiPost } from './client';
|
||||
import type { Channel, CreateChannelRequest, Message } from '../types/api';
|
||||
|
||||
export async function createChannel(data: CreateChannelRequest): Promise<Channel> {
|
||||
return apiPost<Channel>('/channels', data);
|
||||
}
|
||||
|
||||
export async function getChannels(teamId: string): Promise<{ channels: Channel[] }> {
|
||||
return apiGet<{ channels: Channel[] }>(`/channels?team_id=${teamId}`);
|
||||
}
|
||||
|
||||
export async function getChannelMessages(
|
||||
channelId: string,
|
||||
cursor?: string,
|
||||
limit = 50
|
||||
): Promise<{ messages: Message[]; next_cursor?: string }> {
|
||||
const params = new URLSearchParams();
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
params.set('limit', limit.toString());
|
||||
return apiGet<{ messages: Message[]; next_cursor?: string }>(
|
||||
`/channels/${channelId}/messages?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicChannel(slug: string): Promise<Channel> {
|
||||
return apiGet<Channel>(`/channels/public/${slug}`);
|
||||
}
|
||||
|
||||
83
src/api/client.ts
Normal file
83
src/api/client.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// API Client для MicroDAO
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://api.microdao.xyz';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public data?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
// Перевіряємо localStorage або httpOnly cookie
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
async function fetchApi(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetchApi(endpoint, { method: 'GET' });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiPost<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
const response = await fetchApi(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(endpoint: string, data: unknown): Promise<T> {
|
||||
const response = await fetchApi(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetchApi(endpoint, { method: 'DELETE' });
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
19
src/api/teams.ts
Normal file
19
src/api/teams.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { apiGet, apiPost, apiPatch } from './client';
|
||||
import type { Team, CreateTeamRequest, UpdateTeamRequest } from '../types/api';
|
||||
|
||||
export async function createTeam(data: CreateTeamRequest): Promise<Team> {
|
||||
return apiPost<Team>('/teams', data);
|
||||
}
|
||||
|
||||
export async function getTeams(): Promise<{ teams: Team[] }> {
|
||||
return apiGet<{ teams: Team[] }>('/teams');
|
||||
}
|
||||
|
||||
export async function getTeam(teamId: string): Promise<Team> {
|
||||
return apiGet<Team>(`/teams/${teamId}`);
|
||||
}
|
||||
|
||||
export async function updateTeam(teamId: string, data: UpdateTeamRequest): Promise<Team> {
|
||||
return apiPatch<Team>(`/teams/${teamId}`, data);
|
||||
}
|
||||
|
||||
79
src/components/onboarding/OnboardingStepper.tsx
Normal file
79
src/components/onboarding/OnboardingStepper.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface Step {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface OnboardingStepperProps {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{ number: 1, title: 'Ласкаво просимо' },
|
||||
{ number: 2, title: 'Створити спільноту' },
|
||||
{ number: 3, title: 'Режим приватності' },
|
||||
{ number: 4, title: 'Перший канал' },
|
||||
{ number: 5, title: 'Агент та пам\'ять' },
|
||||
{ number: 6, title: 'Запросити команду' },
|
||||
];
|
||||
|
||||
export function OnboardingStepper({ currentStep, totalSteps }: OnboardingStepperProps) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = step.number < currentStep;
|
||||
const isCurrent = step.number === currentStep;
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.number}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className="flex items-center w-full">
|
||||
{/* Step Circle */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-emerald-600 border-emerald-600 text-white'
|
||||
: isCurrent
|
||||
? 'bg-slate-900 border-slate-900 text-white'
|
||||
: 'bg-white border-slate-300 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-5 h-5" />
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{step.number}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Step Title */}
|
||||
<div className="ml-3 flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
isCurrent ? 'text-slate-900' : isCompleted ? 'text-slate-600' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className={`h-0.5 flex-1 mx-4 ${
|
||||
isCompleted ? 'bg-emerald-600' : 'bg-slate-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
199
src/components/onboarding/StepAgentSettings.tsx
Normal file
199
src/components/onboarding/StepAgentSettings.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { createAgent } from '../../api/agents';
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { Team, Agent } from '../../types/api';
|
||||
|
||||
interface StepAgentSettingsProps {
|
||||
team: Team;
|
||||
agentEnabled: boolean;
|
||||
agentLanguage: 'uk' | 'en';
|
||||
agentFocus: 'general' | 'business' | 'it' | 'creative';
|
||||
useCoMemory: boolean;
|
||||
onUpdate: (updates: {
|
||||
agentEnabled: boolean;
|
||||
agentLanguage: 'uk' | 'en';
|
||||
agentFocus: 'general' | 'business' | 'it' | 'creative';
|
||||
useCoMemory: boolean;
|
||||
}) => void;
|
||||
onNext: (agent: Agent | null) => void;
|
||||
}
|
||||
|
||||
export function StepAgentSettings({
|
||||
team,
|
||||
agentEnabled,
|
||||
agentLanguage,
|
||||
agentFocus,
|
||||
useCoMemory,
|
||||
onUpdate,
|
||||
onNext,
|
||||
}: StepAgentSettingsProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!agentEnabled) {
|
||||
onNext(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const agent = await createAgent({
|
||||
team_id: team.id,
|
||||
name: 'Team Assistant',
|
||||
role: agentFocus,
|
||||
language: agentLanguage,
|
||||
focus: agentFocus,
|
||||
use_co_memory: useCoMemory,
|
||||
});
|
||||
onNext(agent);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || 'Не вдалося створити агента. Спробуйте ще раз.');
|
||||
} else {
|
||||
setError('Сталася помилка. Спробуйте ще раз.');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
Агенти та пам'ять
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Налаштуй свого приватного агента для цієї спільноти.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Enable Agent */}
|
||||
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agentEnabled"
|
||||
checked={agentEnabled}
|
||||
onChange={(e) => onUpdate({
|
||||
agentEnabled: e.target.checked,
|
||||
agentLanguage,
|
||||
agentFocus,
|
||||
useCoMemory,
|
||||
})}
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="agentEnabled" className="flex-1 cursor-pointer">
|
||||
<div className="font-medium text-slate-900">Увімкнути мого приватного агента в цій спільноті</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{agentEnabled && (
|
||||
<>
|
||||
{/* Language & Focus */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="agentLanguage" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Мова
|
||||
</label>
|
||||
<select
|
||||
id="agentLanguage"
|
||||
value={agentLanguage}
|
||||
onChange={(e) => onUpdate({
|
||||
agentEnabled,
|
||||
agentLanguage: e.target.value as 'uk' | 'en',
|
||||
agentFocus,
|
||||
useCoMemory,
|
||||
})}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="uk">Українська</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="agentFocus" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Фокус агента
|
||||
</label>
|
||||
<select
|
||||
id="agentFocus"
|
||||
value={agentFocus}
|
||||
onChange={(e) => onUpdate({
|
||||
agentEnabled,
|
||||
agentLanguage,
|
||||
agentFocus: e.target.value as 'general' | 'business' | 'it' | 'creative',
|
||||
useCoMemory,
|
||||
})}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="general">Загальний</option>
|
||||
<option value="business">Бізнес</option>
|
||||
<option value="it">IT</option>
|
||||
<option value="creative">Креатив</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Co-Memory Toggle */}
|
||||
<div className="flex items-start gap-3 p-4 border border-slate-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCoMemory"
|
||||
checked={useCoMemory}
|
||||
onChange={(e) => onUpdate({
|
||||
agentEnabled,
|
||||
agentLanguage,
|
||||
agentFocus,
|
||||
useCoMemory: e.target.checked,
|
||||
})}
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="useCoMemory" className="flex-1 cursor-pointer">
|
||||
<div className="font-medium text-slate-900">
|
||||
Дозволити агенту використовувати Co-Memory цієї спільноти для відповідей
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 mt-1">
|
||||
Co-Memory = файли, посилання, wiki, які команда додає
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
<div className="p-4 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm text-slate-600">
|
||||
Агент допомагає в чатах, читає контекст та Co-Memory, пропонує підсумки та фоллоу-апи.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-2">
|
||||
У майбутньому ти зможеш навчати агента й заробляти токени 1T.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
141
src/components/onboarding/StepCreateChannel.tsx
Normal file
141
src/components/onboarding/StepCreateChannel.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { createChannel } from '../../api/channels';
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { Team, Channel } from '../../types/api';
|
||||
|
||||
interface StepCreateChannelProps {
|
||||
team: Team;
|
||||
channelName: string;
|
||||
channelType: 'public' | 'group';
|
||||
onUpdate: (updates: { channelName: string; channelType: 'public' | 'group' }) => void;
|
||||
onNext: (channel: Channel) => void;
|
||||
}
|
||||
|
||||
export function StepCreateChannel({
|
||||
team,
|
||||
channelName,
|
||||
channelType,
|
||||
onUpdate,
|
||||
onNext,
|
||||
}: StepCreateChannelProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!channelName.trim()) {
|
||||
setError('Назва каналу обов\'язкова');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const channel = await createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName.trim().replace(/^#/, ''), // Remove # if user added it
|
||||
type: channelType,
|
||||
});
|
||||
onNext(channel);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || 'Не вдалося створити канал. Спробуйте ще раз.');
|
||||
} else {
|
||||
setError('Сталася помилка. Спробуйте ще раз.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
Перший канал
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Створи свій перший канал для спілкування в спільноті.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="channelName" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Назва каналу <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">#</span>
|
||||
<input
|
||||
id="channelName"
|
||||
type="text"
|
||||
value={channelName}
|
||||
onChange={(e) => onUpdate({ channelName: e.target.value, channelType })}
|
||||
placeholder="general"
|
||||
className="w-full pl-8 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Тип каналу
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-3 p-4 border-2 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="channelType"
|
||||
value="public"
|
||||
checked={channelType === 'public'}
|
||||
onChange={(e) => onUpdate({ channelName, channelType: e.target.value as 'public' | 'group' })}
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Публічний канал спільноти</div>
|
||||
<div className="text-sm text-slate-600">Як landing / стрічка для всіх учасників</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 p-4 border-2 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="channelType"
|
||||
value="group"
|
||||
checked={channelType === 'group'}
|
||||
onChange={(e) => onUpdate({ channelName, channelType: e.target.value as 'public' | 'group' })}
|
||||
className="mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Приватна кімната команди</div>
|
||||
<div className="text-sm text-slate-600">Тільки для запрошених учасників</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !channelName.trim()}
|
||||
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Створити канал
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
111
src/components/onboarding/StepCreateTeam.tsx
Normal file
111
src/components/onboarding/StepCreateTeam.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { createTeam } from '../../api/teams';
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { Team } from '../../types/api';
|
||||
|
||||
interface StepCreateTeamProps {
|
||||
teamName: string;
|
||||
teamDescription: string;
|
||||
onUpdate: (updates: { teamName: string; teamDescription: string }) => void;
|
||||
onNext: (team: Team) => void;
|
||||
}
|
||||
|
||||
export function StepCreateTeam({
|
||||
teamName,
|
||||
teamDescription,
|
||||
onUpdate,
|
||||
onNext,
|
||||
}: StepCreateTeamProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!teamName.trim()) {
|
||||
setError('Назва спільноти обов\'язкова');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const team = await createTeam({
|
||||
name: teamName.trim(),
|
||||
description: teamDescription.trim() || undefined,
|
||||
});
|
||||
onNext(team);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || 'Не вдалося створити спільноту. Спробуйте ще раз.');
|
||||
} else {
|
||||
setError('Сталася помилка. Спробуйте ще раз.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
Створити спільноту
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Спільнота = твоя команда, клуб чи проект. Для кожної спільноти автоматично створюється micro-DAO з власним управлінням і агентами.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="teamName" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Назва спільноти <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="teamName"
|
||||
type="text"
|
||||
value={teamName}
|
||||
onChange={(e) => onUpdate({ teamName: e.target.value, teamDescription })}
|
||||
placeholder="Наприклад: Моя команда"
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="teamDescription" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Короткий опис <span className="text-slate-400 text-xs">(опційно)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="teamDescription"
|
||||
value={teamDescription}
|
||||
onChange={(e) => onUpdate({ teamName, teamDescription: e.target.value })}
|
||||
placeholder="Опиши свою спільноту..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent resize-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !teamName.trim()}
|
||||
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Продовжити
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/components/onboarding/StepInvite.tsx
Normal file
97
src/components/onboarding/StepInvite.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Copy, Check, QrCode } from 'lucide-react';
|
||||
import type { Team, Channel } from '../../types/api';
|
||||
|
||||
interface StepInviteProps {
|
||||
team: Team;
|
||||
channel: Channel;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function StepInvite({ team, channel, onComplete }: StepInviteProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// TODO: Отримати реальне посилання-запрошення з API
|
||||
const inviteLink = `${window.location.origin}/invite/${team.id}`;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
Все готово!
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Залишилось запросити команду в твою спільноту.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Invite Link */}
|
||||
<div className="p-4 border border-slate-200 rounded-lg bg-slate-50">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Посилання-запрошення
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inviteLink}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-white border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 flex items-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Скопійовано
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
Копіювати
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Показати QR код (модалка)
|
||||
alert('QR код буде доступний пізніше');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
Показати QR
|
||||
</button>
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="flex-1 px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2"
|
||||
>
|
||||
Перейти в чат
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<p className="text-sm text-emerald-800">
|
||||
<strong>Спільнота "{team.name}"</strong> створена успішно! Тепер ти можеш почати спілкування в каналі <strong>#{channel.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
147
src/components/onboarding/StepSelectMode.tsx
Normal file
147
src/components/onboarding/StepSelectMode.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { Globe, Lock } from 'lucide-react';
|
||||
import { updateTeam } from '../../api/teams';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { Team } from '../../types/api';
|
||||
|
||||
interface StepSelectModeProps {
|
||||
team: Team;
|
||||
selectedMode: 'public' | 'confidential';
|
||||
onUpdate: (mode: 'public' | 'confidential') => void;
|
||||
onNext: (team: Team) => void;
|
||||
}
|
||||
|
||||
export function StepSelectMode({
|
||||
team,
|
||||
selectedMode,
|
||||
onUpdate,
|
||||
onNext,
|
||||
}: StepSelectModeProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedMode === team.mode) {
|
||||
onNext(team);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const updatedTeam = await updateTeam(team.id, { mode: selectedMode });
|
||||
onNext(updatedTeam);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || 'Не вдалося оновити режим спільноти.');
|
||||
} else {
|
||||
setError('Сталася помилка. Спробуйте ще раз.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
Режим приватності
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Обери, як твоя спільнота буде доступна для інших.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Public Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdate('public')}
|
||||
disabled={loading}
|
||||
className={`w-full p-6 border-2 rounded-xl text-left transition-all ${
|
||||
selectedMode === 'public'
|
||||
? 'border-slate-900 bg-slate-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center mt-1 ${
|
||||
selectedMode === 'public'
|
||||
? 'border-slate-900 bg-slate-900'
|
||||
: 'border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{selectedMode === 'public' && (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Globe className="w-5 h-5 text-slate-600" />
|
||||
<h3 className="text-lg font-semibold text-slate-900">Public</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Є публічний канал, гості можуть читати і реєструватися як глядачі (viewer-type).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Confidential Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdate('confidential')}
|
||||
disabled={loading}
|
||||
className={`w-full p-6 border-2 rounded-xl text-left transition-all ${
|
||||
selectedMode === 'confidential'
|
||||
? 'border-slate-900 bg-slate-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center mt-1 ${
|
||||
selectedMode === 'confidential'
|
||||
? 'border-slate-900 bg-slate-900'
|
||||
: 'border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{selectedMode === 'confidential' && (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Lock className="w-5 h-5 text-slate-600" />
|
||||
<h3 className="text-lg font-semibold text-slate-900">Confidential</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Тільки запрошені учасники, E2EE для чатів, без публічного індексування.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Продовжити
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/components/onboarding/StepWelcome.tsx
Normal file
30
src/components/onboarding/StepWelcome.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StepWelcomeProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function StepWelcome({ onNext }: StepWelcomeProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-6">
|
||||
Ласкаво просимо до MicroDAO
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600 mb-4 leading-relaxed">
|
||||
MicroDAO — приватна мережа ШІ-агентів для малих спільнот.
|
||||
</p>
|
||||
<p className="text-lg text-slate-600 mb-8 leading-relaxed">
|
||||
За 3 кроки ти створиш власну micro-DAO: спільноту, перший канал і свого агента.
|
||||
</p>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-8 py-3 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-2"
|
||||
>
|
||||
Почати
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
58
src/hooks/useOnboarding.ts
Normal file
58
src/hooks/useOnboarding.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import type { Team, Channel, Agent } from '../types/api';
|
||||
|
||||
export interface OnboardingState {
|
||||
// Step 2: Create Team
|
||||
teamName: string;
|
||||
teamDescription: string;
|
||||
team: Team | null;
|
||||
|
||||
// Step 3: Select Mode
|
||||
teamMode: 'public' | 'confidential';
|
||||
|
||||
// Step 4: Create Channel
|
||||
channelName: string;
|
||||
channelType: 'public' | 'group';
|
||||
channel: Channel | null;
|
||||
|
||||
// Step 5: Agent Settings
|
||||
agentEnabled: boolean;
|
||||
agentLanguage: 'uk' | 'en';
|
||||
agentFocus: 'general' | 'business' | 'it' | 'creative';
|
||||
useCoMemory: boolean;
|
||||
agent: Agent | null;
|
||||
}
|
||||
|
||||
const initialState: OnboardingState = {
|
||||
teamName: '',
|
||||
teamDescription: '',
|
||||
team: null,
|
||||
teamMode: 'public',
|
||||
channelName: '',
|
||||
channelType: 'public',
|
||||
channel: null,
|
||||
agentEnabled: false,
|
||||
agentLanguage: 'uk',
|
||||
agentFocus: 'general',
|
||||
useCoMemory: false,
|
||||
agent: null,
|
||||
};
|
||||
|
||||
export function useOnboarding() {
|
||||
const [state, setState] = useState<OnboardingState>(initialState);
|
||||
|
||||
const updateState = (updates: Partial<OnboardingState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setState(initialState);
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
updateState,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
151
src/pages/OnboardingPage.tsx
Normal file
151
src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { OnboardingStepper } from '../components/onboarding/OnboardingStepper';
|
||||
import { StepWelcome } from '../components/onboarding/StepWelcome';
|
||||
import { StepCreateTeam } from '../components/onboarding/StepCreateTeam';
|
||||
import { StepSelectMode } from '../components/onboarding/StepSelectMode';
|
||||
import { StepCreateChannel } from '../components/onboarding/StepCreateChannel';
|
||||
import { StepAgentSettings } from '../components/onboarding/StepAgentSettings';
|
||||
import { StepInvite } from '../components/onboarding/StepInvite';
|
||||
import { useOnboarding } from '../hooks/useOnboarding';
|
||||
import type { Team, Channel, Agent } from '../types/api';
|
||||
|
||||
const TOTAL_STEPS = 6;
|
||||
|
||||
export function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { state, updateState } = useOnboarding();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep2Complete = (team: Team) => {
|
||||
updateState({ team, teamName: team.name, teamDescription: team.description || '' });
|
||||
setCurrentStep(3);
|
||||
};
|
||||
|
||||
const handleStep3Complete = (team: Team) => {
|
||||
updateState({ team, teamMode: team.mode });
|
||||
setCurrentStep(4);
|
||||
};
|
||||
|
||||
const handleStep4Complete = (channel: Channel) => {
|
||||
updateState({ channel, channelName: channel.name, channelType: channel.type });
|
||||
setCurrentStep(5);
|
||||
};
|
||||
|
||||
const handleStep5Complete = (agent: Agent | null) => {
|
||||
updateState({ agent });
|
||||
setCurrentStep(6);
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
// Перенаправляємо на головну сторінку чату з створеним каналом
|
||||
if (state.channel) {
|
||||
navigate(`/teams/${state.team?.id}/channels/${state.channel.id}`);
|
||||
} else if (state.team) {
|
||||
navigate(`/teams/${state.team.id}`);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <StepWelcome onNext={handleNext} />;
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<StepCreateTeam
|
||||
teamName={state.teamName}
|
||||
teamDescription={state.teamDescription}
|
||||
onUpdate={(updates) => updateState(updates)}
|
||||
onNext={handleStep2Complete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 3:
|
||||
if (!state.team) {
|
||||
// Якщо команда не створена, повертаємось на крок 2
|
||||
setCurrentStep(2);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<StepSelectMode
|
||||
team={state.team}
|
||||
selectedMode={state.teamMode}
|
||||
onUpdate={(mode) => updateState({ teamMode: mode })}
|
||||
onNext={handleStep3Complete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 4:
|
||||
if (!state.team) {
|
||||
setCurrentStep(2);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<StepCreateChannel
|
||||
team={state.team}
|
||||
channelName={state.channelName}
|
||||
channelType={state.channelType}
|
||||
onUpdate={(updates) => updateState(updates)}
|
||||
onNext={handleStep4Complete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 5:
|
||||
if (!state.team) {
|
||||
setCurrentStep(2);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<StepAgentSettings
|
||||
team={state.team}
|
||||
agentEnabled={state.agentEnabled}
|
||||
agentLanguage={state.agentLanguage}
|
||||
agentFocus={state.agentFocus}
|
||||
useCoMemory={state.useCoMemory}
|
||||
onUpdate={(updates) => updateState(updates)}
|
||||
onNext={handleStep5Complete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 6:
|
||||
if (!state.team || !state.channel) {
|
||||
setCurrentStep(2);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<StepInvite
|
||||
team={state.team}
|
||||
channel={state.channel}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Stepper */}
|
||||
{currentStep > 1 && (
|
||||
<OnboardingStepper currentStep={currentStep} totalSteps={TOTAL_STEPS} />
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="mt-8">{renderStep()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
src/types/api.ts
Normal file
95
src/types/api.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// API Types для MicroDAO
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
plan: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
mode: 'public' | 'confidential';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTeamRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTeamRequest {
|
||||
mode?: 'public' | 'confidential';
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
team_id: string;
|
||||
name: string;
|
||||
type: 'public' | 'group';
|
||||
slug: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateChannelRequest {
|
||||
team_id: string;
|
||||
name: string;
|
||||
type: 'public' | 'group';
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface CreateMessageRequest {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
team_id: string;
|
||||
name: string;
|
||||
role: 'general' | 'business' | 'it' | 'creative';
|
||||
language: 'uk' | 'en';
|
||||
focus: 'general' | 'business' | 'it' | 'creative';
|
||||
use_co_memory: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
team_id: string;
|
||||
name: string;
|
||||
role: 'general' | 'business' | 'it' | 'creative';
|
||||
language: 'uk' | 'en';
|
||||
focus: 'general' | 'business' | 'it' | 'creative';
|
||||
use_co_memory: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface LoginEmailRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ExchangeCodeRequest {
|
||||
code: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user