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

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