Initial commit: MVP structure + Cursor documentation + Onboarding components

This commit is contained in:
Apple
2025-11-13 06:12:20 -08:00
commit 5520665600
58 changed files with 7683 additions and 0 deletions

51
src/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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
View 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;
}