Initial commit: MVP structure + Cursor documentation + Onboarding components
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user