Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.
This commit is contained in:
@ -23,6 +23,7 @@ interface OrganizationData {
|
||||
interface ApiKeyValidation {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,7 @@ interface OrganizationData {
|
||||
interface ApiKeyValidation {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
@ -249,9 +250,9 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
|
||||
WB
|
||||
</Badge>
|
||||
</div>
|
||||
{data.wbApiValidation?.sellerName ? (
|
||||
{data.wbApiValidation?.tradeMark || data.wbApiValidation?.sellerName ? (
|
||||
<span className="text-white/70 text-xs max-w-[120px] text-right truncate">
|
||||
{data.wbApiValidation.sellerName}
|
||||
{data.wbApiValidation.tradeMark || data.wbApiValidation.sellerName}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="glass-secondary text-green-300 border-green-400/30 text-xs flex items-center gap-1">
|
||||
@ -263,9 +264,17 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
|
||||
|
||||
{data.wbApiValidation && (
|
||||
<>
|
||||
{data.wbApiValidation.sellerName && (
|
||||
{data.wbApiValidation.tradeMark && (
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<span className="text-white/50 text-xs">Магазин:</span>
|
||||
<span className="text-white/50 text-xs">Торговая марка:</span>
|
||||
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
|
||||
{data.wbApiValidation.tradeMark}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.wbApiValidation.sellerName && data.wbApiValidation.sellerName !== data.wbApiValidation.tradeMark && (
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<span className="text-white/50 text-xs">Продавец:</span>
|
||||
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
|
||||
{data.wbApiValidation.sellerName}
|
||||
</span>
|
||||
|
@ -15,6 +15,7 @@ import { getAuthToken } from '@/lib/apollo-client'
|
||||
interface ApiValidationData {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
@ -97,12 +98,14 @@ export function MarketplaceApiStep({ onNext, onBack }: MarketplaceApiStepProps)
|
||||
setWbValidationData({
|
||||
sellerId: validationData.sellerId,
|
||||
sellerName: validationData.sellerName,
|
||||
tradeMark: validationData.tradeMark,
|
||||
isValid: true
|
||||
})
|
||||
} else if (marketplace === 'ozon') {
|
||||
setOzonValidationData({
|
||||
sellerId: validationData.sellerId,
|
||||
sellerName: validationData.sellerName,
|
||||
tradeMark: validationData.tradeMark,
|
||||
isValid: true
|
||||
})
|
||||
}
|
||||
|
@ -10,7 +10,8 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
Building2,
|
||||
Store
|
||||
Store,
|
||||
MessageCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
export function Sidebar() {
|
||||
@ -58,8 +59,13 @@ export function Sidebar() {
|
||||
router.push('/market')
|
||||
}
|
||||
|
||||
const handleMessengerClick = () => {
|
||||
router.push('/messenger')
|
||||
}
|
||||
|
||||
const isSettingsActive = pathname === '/settings'
|
||||
const isMarketActive = pathname.startsWith('/market')
|
||||
const isMessengerActive = pathname.startsWith('/messenger')
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
|
||||
@ -67,30 +73,33 @@ export function Sidebar() {
|
||||
|
||||
|
||||
{/* Информация о пользователе */}
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-10 w-10">
|
||||
{user?.avatar ? (
|
||||
<AvatarImage
|
||||
src={user.avatar}
|
||||
alt="Аватар пользователя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Card className="bg-gradient-to-br from-white/15 to-white/5 backdrop-blur border border-white/30 p-4 mb-4 shadow-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<Avatar className="h-12 w-12 flex-shrink-0 ring-2 ring-white/20">
|
||||
{user?.avatar ? (
|
||||
<AvatarImage
|
||||
src={user.avatar}
|
||||
alt="Аватар пользователя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-sm font-semibold">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-400 rounded-full border-2 border-white/20"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-1 mb-1">
|
||||
<Building2 className="h-3 w-3 text-white/60" />
|
||||
<p className="text-white text-xs font-medium truncate">
|
||||
{getOrganizationName()}
|
||||
<p className="text-white text-sm font-semibold truncate mb-1" title={getOrganizationName()}>
|
||||
{getOrganizationName()}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 bg-purple-400 rounded-full flex-shrink-0"></div>
|
||||
<p className="text-white/70 text-xs font-medium">
|
||||
{getCabinetType()}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-white/60 text-xs truncate">
|
||||
{getCabinetType()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@ -109,6 +118,19 @@ export function Sidebar() {
|
||||
<Store className="h-3 w-3 mr-2" />
|
||||
Маркет
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isMessengerActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
isMessengerActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleMessengerClick}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3 mr-2" />
|
||||
Мессенджер
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isSettingsActive ? "secondary" : "ghost"}
|
||||
|
@ -70,7 +70,11 @@ export function UserSettings() {
|
||||
bankName: '',
|
||||
bik: '',
|
||||
accountNumber: '',
|
||||
corrAccount: ''
|
||||
corrAccount: '',
|
||||
|
||||
// API ключи маркетплейсов
|
||||
wildberriesApiKey: '',
|
||||
ozonApiKey: ''
|
||||
})
|
||||
|
||||
// Загружаем данные организации при монтировании компонента
|
||||
@ -122,7 +126,7 @@ export function UserSettings() {
|
||||
|
||||
setFormData({
|
||||
orgPhone: orgPhone,
|
||||
managerName: customContacts?.managerName || '',
|
||||
managerName: user?.managerName || '',
|
||||
telegram: customContacts?.telegram || '',
|
||||
whatsapp: customContacts?.whatsapp || '',
|
||||
email: email,
|
||||
@ -135,7 +139,9 @@ export function UserSettings() {
|
||||
bankName: customContacts?.bankDetails?.bankName || '',
|
||||
bik: customContacts?.bankDetails?.bik || '',
|
||||
accountNumber: customContacts?.bankDetails?.accountNumber || '',
|
||||
corrAccount: customContacts?.bankDetails?.corrAccount || ''
|
||||
corrAccount: customContacts?.bankDetails?.corrAccount || '',
|
||||
wildberriesApiKey: '',
|
||||
ozonApiKey: ''
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
@ -176,8 +182,8 @@ export function UserSettings() {
|
||||
|
||||
// Дополнительные поля в зависимости от типа кабинета
|
||||
const additionalFields = []
|
||||
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') {
|
||||
// Финансовые данные - всегда обязательны для бизнес-кабинетов
|
||||
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') {
|
||||
// Финансовые данные - всегда обязательны для всех типов кабинетов
|
||||
additionalFields.push(
|
||||
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
|
||||
{ field: 'bik', label: 'БИК', value: formData.bik },
|
||||
@ -386,13 +392,30 @@ export function UserSettings() {
|
||||
const cleaned = value.replace(/\D/g, '')
|
||||
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
|
||||
case 'telegram':
|
||||
return value.length < 6 ? 'Минимум 5 символов после @' : null
|
||||
// Проверяем что после @ есть минимум 5 символов
|
||||
const usernameLength = value.startsWith('@') ? value.length - 1 : value.length
|
||||
return usernameLength < 5 ? 'Минимум 5 символов после @' : null
|
||||
case 'inn':
|
||||
// Игнорируем автоматически сгенерированные ИНН селлеров
|
||||
if (value.startsWith('SELLER_')) {
|
||||
return null
|
||||
}
|
||||
const innCleaned = value.replace(/\D/g, '')
|
||||
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
|
||||
return 'ИНН должен содержать 10 или 12 цифр'
|
||||
}
|
||||
return null
|
||||
case 'bankName':
|
||||
return value.trim().length < 3 ? 'Минимум 3 символа' : null
|
||||
case 'bik':
|
||||
const bikCleaned = value.replace(/\D/g, '')
|
||||
return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null
|
||||
case 'accountNumber':
|
||||
const accountCleaned = value.replace(/\D/g, '')
|
||||
return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null
|
||||
case 'corrAccount':
|
||||
const corrCleaned = value.replace(/\D/g, '')
|
||||
return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@ -400,11 +423,20 @@ export function UserSettings() {
|
||||
|
||||
// Проверка наличия ошибок валидации
|
||||
const hasValidationErrors = () => {
|
||||
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn']
|
||||
return fields.some(field => {
|
||||
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn', 'bankName', 'bik', 'accountNumber', 'corrAccount']
|
||||
|
||||
// Проверяем ошибки валидации только в заполненных полях
|
||||
const hasErrors = fields.some(field => {
|
||||
const value = formData[field as keyof typeof formData]
|
||||
return getFieldError(field, value)
|
||||
// Проверяем ошибки только для заполненных полей
|
||||
if (!value || !value.trim()) return false
|
||||
|
||||
const error = getFieldError(field, value)
|
||||
return error !== null
|
||||
})
|
||||
|
||||
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
@ -437,19 +469,33 @@ export function UserSettings() {
|
||||
setSaveMessage({ type: 'success', text: 'Данные организации обновлены. Сохраняем профиль...' })
|
||||
}
|
||||
|
||||
// Подготавливаем только заполненные поля для отправки
|
||||
const inputData: {
|
||||
orgPhone?: string
|
||||
managerName?: string
|
||||
telegram?: string
|
||||
whatsapp?: string
|
||||
email?: string
|
||||
bankName?: string
|
||||
bik?: string
|
||||
accountNumber?: string
|
||||
corrAccount?: string
|
||||
} = {}
|
||||
|
||||
// orgName больше не редактируется - устанавливается только при регистрации
|
||||
if (formData.orgPhone?.trim()) inputData.orgPhone = formData.orgPhone.trim()
|
||||
if (formData.managerName?.trim()) inputData.managerName = formData.managerName.trim()
|
||||
if (formData.telegram?.trim()) inputData.telegram = formData.telegram.trim()
|
||||
if (formData.whatsapp?.trim()) inputData.whatsapp = formData.whatsapp.trim()
|
||||
if (formData.email?.trim()) inputData.email = formData.email.trim()
|
||||
if (formData.bankName?.trim()) inputData.bankName = formData.bankName.trim()
|
||||
if (formData.bik?.trim()) inputData.bik = formData.bik.trim()
|
||||
if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim()
|
||||
if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.trim()
|
||||
|
||||
const result = await updateUserProfile({
|
||||
variables: {
|
||||
input: {
|
||||
orgPhone: formData.orgPhone,
|
||||
managerName: formData.managerName,
|
||||
telegram: formData.telegram,
|
||||
whatsapp: formData.whatsapp,
|
||||
email: formData.email,
|
||||
bankName: formData.bankName,
|
||||
bik: formData.bik,
|
||||
accountNumber: formData.accountNumber,
|
||||
corrAccount: formData.corrAccount
|
||||
}
|
||||
input: inputData
|
||||
}
|
||||
})
|
||||
|
||||
@ -516,19 +562,18 @@ export function UserSettings() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Компактный индикатор прогресса */}
|
||||
{isIncomplete && (
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-white/70">
|
||||
Осталось {profileStatus.missingFields.length} {
|
||||
profileStatus.missingFields.length === 1 ? 'поле' :
|
||||
profileStatus.missingFields.length < 5 ? 'поля' : 'полей'
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:block text-xs text-white/70">
|
||||
{isIncomplete ? (
|
||||
<>Заполнено {profileStatus.percentage}% профиля</>
|
||||
) : (
|
||||
<>Профиль полностью заполнен</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
@ -590,10 +635,10 @@ export function UserSettings() {
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Организация
|
||||
</TabsTrigger>
|
||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
|
||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
|
||||
<TabsTrigger value="financial" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Финансовые
|
||||
Финансы
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{user?.organization?.type === 'SELLER' && (
|
||||
@ -602,10 +647,12 @@ export function UserSettings() {
|
||||
API
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Инструменты
|
||||
</TabsTrigger>
|
||||
{user?.organization?.type !== 'SELLER' && (
|
||||
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Инструменты
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Профиль пользователя */}
|
||||
@ -671,7 +718,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
|
||||
<Input
|
||||
value={formData.orgPhone}
|
||||
value={formData.orgPhone || ''}
|
||||
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
readOnly={!isEditing}
|
||||
@ -679,23 +726,18 @@ export function UserSettings() {
|
||||
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
|
||||
}`}
|
||||
/>
|
||||
{getFieldError('orgPhone', formData.orgPhone) ? (
|
||||
{getFieldError('orgPhone', formData.orgPhone) && (
|
||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{getFieldError('orgPhone', formData.orgPhone)}
|
||||
</p>
|
||||
) : !formData.orgPhone && (
|
||||
<p className="text-orange-400 text-xs mt-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Рекомендуется указать
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
|
||||
<Input
|
||||
value={formData.managerName}
|
||||
value={formData.managerName || ''}
|
||||
onChange={(e) => handleInputChange('managerName', e.target.value)}
|
||||
placeholder="Иван Иванов"
|
||||
readOnly={!isEditing}
|
||||
@ -719,7 +761,7 @@ export function UserSettings() {
|
||||
Telegram
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.telegram}
|
||||
value={formData.telegram || ''}
|
||||
onChange={(e) => handleInputChange('telegram', e.target.value)}
|
||||
placeholder="@username"
|
||||
readOnly={!isEditing}
|
||||
@ -741,7 +783,7 @@ export function UserSettings() {
|
||||
WhatsApp
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.whatsapp}
|
||||
value={formData.whatsapp || ''}
|
||||
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
readOnly={!isEditing}
|
||||
@ -764,7 +806,7 @@ export function UserSettings() {
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="example@company.com"
|
||||
readOnly={!isEditing}
|
||||
@ -807,14 +849,25 @@ export function UserSettings() {
|
||||
{/* Названия */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Название организации</Label>
|
||||
<Label className="text-white/80 text-sm mb-2 block">
|
||||
{user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.orgName || user?.organization?.name || ''}
|
||||
onChange={(e) => handleInputChange('orgName', e.target.value)}
|
||||
placeholder="Название организации"
|
||||
readOnly={!isEditing || !!(formData.orgName || user?.organization?.name)}
|
||||
placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
||||
readOnly={true}
|
||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||
/>
|
||||
{user?.organization?.type === 'SELLER' ? (
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
Название устанавливается при регистрации кабинета и не может быть изменено.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
Автоматически заполняется из федерального реестра при указании ИНН.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -904,27 +957,29 @@ export function UserSettings() {
|
||||
|
||||
{/* Руководитель и статус */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{user?.organization?.managementName && (
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Руководитель</Label>
|
||||
<Input
|
||||
value={user.organization.managementName}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
|
||||
<Input
|
||||
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
placeholder="Данные отсутствуют в федеральном реестре"
|
||||
/>
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
{user?.organization?.managementName
|
||||
? 'Данные из федерального реестра'
|
||||
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user?.organization?.status && (
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
||||
<Input
|
||||
value={user.organization.status === 'ACTIVE' ? 'Действующая' : user.organization.status}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
||||
<Input
|
||||
value={user?.organization?.status === 'ACTIVE' ? 'Действующая' : user?.organization?.status || 'Статус не указан'}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дата регистрации */}
|
||||
@ -950,7 +1005,7 @@ export function UserSettings() {
|
||||
|
||||
|
||||
{/* Финансовые данные */}
|
||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
|
||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
|
||||
<TabsContent value="financial" className="flex-1 overflow-hidden">
|
||||
<Card className="glass-card p-6 h-full overflow-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@ -965,7 +1020,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
|
||||
<Input
|
||||
value={formData.bankName}
|
||||
value={formData.bankName || ''}
|
||||
onChange={(e) => handleInputChange('bankName', e.target.value)}
|
||||
placeholder="ПАО Сбербанк"
|
||||
readOnly={!isEditing}
|
||||
@ -977,7 +1032,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
|
||||
<Input
|
||||
value={formData.bik}
|
||||
value={formData.bik || ''}
|
||||
onChange={(e) => handleInputChange('bik', e.target.value)}
|
||||
placeholder="044525225"
|
||||
readOnly={!isEditing}
|
||||
@ -988,7 +1043,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
|
||||
<Input
|
||||
value={formData.corrAccount}
|
||||
value={formData.corrAccount || ''}
|
||||
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
|
||||
placeholder="30101810400000000225"
|
||||
readOnly={!isEditing}
|
||||
@ -1000,7 +1055,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
|
||||
<Input
|
||||
value={formData.accountNumber}
|
||||
value={formData.accountNumber || ''}
|
||||
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
|
||||
placeholder="40702810123456789012"
|
||||
readOnly={!isEditing}
|
||||
@ -1028,14 +1083,16 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
|
||||
<Input
|
||||
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : ''}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
value={isEditing ? (formData.wildberriesApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : '')}
|
||||
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
|
||||
placeholder="Введите API ключ Wildberries"
|
||||
readOnly={!isEditing}
|
||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||
/>
|
||||
{user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') && (
|
||||
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') || (formData.wildberriesApiKey && isEditing)) && (
|
||||
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
API ключ настроен
|
||||
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -1043,14 +1100,16 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
|
||||
<Input
|
||||
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : ''}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
value={isEditing ? (formData.ozonApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : '')}
|
||||
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
|
||||
placeholder="Введите API ключ Ozon"
|
||||
readOnly={!isEditing}
|
||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||
/>
|
||||
{user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') && (
|
||||
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') || (formData.ozonApiKey && isEditing)) && (
|
||||
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
API ключ настроен
|
||||
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
@ -2,21 +2,18 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Users,
|
||||
Clock,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowUpCircle,
|
||||
ArrowDownCircle
|
||||
} from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||
import { OrganizationAvatar } from './organization-avatar'
|
||||
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS, SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { RESPOND_TO_COUNTERPARTY_REQUEST, CANCEL_COUNTERPARTY_REQUEST, REMOVE_COUNTERPARTY } from '@/graphql/mutations'
|
||||
|
||||
interface Organization {
|
||||
@ -47,28 +44,43 @@ export function MarketCounterparties() {
|
||||
const { data: outgoingData, loading: outgoingLoading, refetch: refetchOutgoing } = useQuery(GET_OUTGOING_REQUESTS)
|
||||
|
||||
const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetchIncoming()
|
||||
refetchCounterparties()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: GET_INCOMING_REQUESTS },
|
||||
{ query: GET_MY_COUNTERPARTIES },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const [cancelRequest] = useMutation(CANCEL_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetchOutgoing()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: GET_OUTGOING_REQUESTS },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const [removeCounterparty] = useMutation(REMOVE_COUNTERPARTY, {
|
||||
onCompleted: () => {
|
||||
refetchCounterparties()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: GET_MY_COUNTERPARTIES },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const handleAcceptRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({
|
||||
variables: { requestId, response: 'ACCEPTED' }
|
||||
variables: { requestId, accept: true }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка при принятии заявки:', error)
|
||||
@ -78,7 +90,7 @@ export function MarketCounterparties() {
|
||||
const handleRejectRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({
|
||||
variables: { requestId, response: 'REJECTED' }
|
||||
variables: { requestId, accept: false }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отклонении заявки:', error)
|
||||
@ -207,50 +219,72 @@ export function MarketCounterparties() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{incomingRequests.map((request: CounterpartyRequest) => (
|
||||
<Card key={request.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{(request.sender.name || request.sender.fullName || 'O').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div key={request.id} className="glass-card p-4 w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<OrganizationAvatar organization={request.sender} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium">
|
||||
{request.sender.name || request.sender.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
||||
{request.message && (
|
||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Clock className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
||||
<div className="flex flex-col space-y-2 mb-3">
|
||||
<h4 className="text-white font-medium text-lg leading-tight">
|
||||
{request.sender.name || request.sender.fullName}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className={
|
||||
request.sender.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
|
||||
request.sender.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||
request.sender.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
|
||||
request.sender.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
|
||||
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}>
|
||||
{request.sender.type === 'FULFILLMENT' ? 'Фулфилмент' :
|
||||
request.sender.type === 'SELLER' ? 'Селлер' :
|
||||
request.sender.type === 'LOGIST' ? 'Логистика' :
|
||||
request.sender.type === 'WHOLESALE' ? 'Оптовик' :
|
||||
request.sender.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
||||
{request.sender.address && (
|
||||
<div className="flex items-center text-white/60 text-sm">
|
||||
<span className="truncate">{request.sender.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{request.message && (
|
||||
<div className="p-2 bg-white/5 rounded border border-white/10">
|
||||
<p className="text-white/80 text-sm italic">"{request.message}"</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-white/40 text-xs">
|
||||
<span>Заявка от {formatDate(request.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 ml-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptRequest(request.id)}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer"
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer flex-1"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Принять
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRejectRequest(request.id)}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer"
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer flex-1"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -269,36 +303,56 @@ export function MarketCounterparties() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{outgoingRequests.map((request: CounterpartyRequest) => (
|
||||
<Card key={request.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{(request.receiver.name || request.receiver.fullName || 'O').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div key={request.id} className="glass-card p-4 w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<OrganizationAvatar organization={request.receiver} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium">
|
||||
{request.receiver.name || request.receiver.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
||||
{request.message && (
|
||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
||||
<div className="flex flex-col space-y-2 mb-3">
|
||||
<h4 className="text-white font-medium text-lg leading-tight">
|
||||
{request.receiver.name || request.receiver.fullName}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className={
|
||||
request.receiver.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
|
||||
request.receiver.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||
request.receiver.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
|
||||
request.receiver.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
|
||||
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}>
|
||||
{request.receiver.type === 'FULFILLMENT' ? 'Фулфилмент' :
|
||||
request.receiver.type === 'SELLER' ? 'Селлер' :
|
||||
request.receiver.type === 'LOGIST' ? 'Логистика' :
|
||||
request.receiver.type === 'WHOLESALE' ? 'Оптовик' :
|
||||
request.receiver.type}
|
||||
</Badge>
|
||||
<Badge className={
|
||||
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :
|
||||
request.status === 'REJECTED' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
|
||||
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}>
|
||||
{request.status === 'PENDING' ? 'Ожидает ответа' : request.status === 'REJECTED' ? 'Отклонено' : request.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
||||
{request.receiver.address && (
|
||||
<div className="flex items-center text-white/60 text-sm">
|
||||
<span className="truncate">{request.receiver.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{request.message && (
|
||||
<div className="p-2 bg-white/5 rounded border border-white/10">
|
||||
<p className="text-white/80 text-sm italic">"{request.message}"</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-white/40 text-xs">
|
||||
<span>Отправлено {formatDate(request.createdAt)}</span>
|
||||
</div>
|
||||
<Badge className={
|
||||
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :
|
||||
request.status === 'REJECTED' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
|
||||
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}>
|
||||
{request.status === 'PENDING' ? 'Ожидает' : request.status === 'REJECTED' ? 'Отклонено' : request.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -308,13 +362,13 @@ export function MarketCounterparties() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelRequest(request.id)}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer ml-4"
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer w-full"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Отменить заявку
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Package } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketFulfillment() {
|
||||
@ -31,9 +34,15 @@ export function MarketFulfillment() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||
{ query: GET_OUTGOING_REQUESTS },
|
||||
{ query: GET_INCOMING_REQUESTS }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketFulfillment() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Truck } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketLogistics() {
|
||||
@ -31,9 +34,15 @@ export function MarketLogistics() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||
{ query: GET_OUTGOING_REQUESTS },
|
||||
{ query: GET_INCOMING_REQUESTS }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketLogistics() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, ShoppingCart } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketSellers() {
|
||||
@ -31,9 +34,15 @@ export function MarketSellers() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||
{ query: GET_OUTGOING_REQUESTS },
|
||||
{ query: GET_INCOMING_REQUESTS }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketSellers() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Boxes } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketWholesale() {
|
||||
@ -31,9 +34,15 @@ export function MarketWholesale() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||
{ query: GET_OUTGOING_REQUESTS },
|
||||
{ query: GET_INCOMING_REQUESTS }
|
||||
],
|
||||
awaitRefetchQueries: true
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketWholesale() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
Calendar,
|
||||
Plus,
|
||||
Send,
|
||||
Trash2
|
||||
Trash2,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
import { OrganizationAvatar } from './organization-avatar'
|
||||
import { useState } from 'react'
|
||||
@ -28,6 +29,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
interface OrganizationCardProps {
|
||||
@ -144,7 +148,12 @@ export function OrganizationCard({
|
||||
<Badge className={getTypeColor(organization.type)}>
|
||||
{getTypeLabel(organization.type)}
|
||||
</Badge>
|
||||
{organization.isCounterparty && (
|
||||
{organization.isCurrentUser && (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30">
|
||||
Это вы
|
||||
</Badge>
|
||||
)}
|
||||
{organization.isCounterparty && !organization.isCurrentUser && (
|
||||
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
|
||||
Уже добавлен
|
||||
</Badge>
|
||||
@ -190,16 +199,29 @@ export function OrganizationCard({
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить из контрагентов
|
||||
</Button>
|
||||
) : organization.isCurrentUser ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="bg-blue-500/10 text-blue-300 border-blue-500/30 w-full opacity-50"
|
||||
>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
Ваша организация
|
||||
</Button>
|
||||
) : (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={organization.isCounterparty}
|
||||
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty)} w-full cursor-pointer`}
|
||||
disabled={organization.isCounterparty || organization.hasOutgoingRequest || organization.hasIncomingRequest}
|
||||
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty || !!organization.hasOutgoingRequest || !!organization.hasIncomingRequest)} w-full cursor-pointer`}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{organization.isCounterparty ? 'Уже добавлен' : actionButtonText}
|
||||
{organization.isCounterparty ? 'Уже добавлен' :
|
||||
organization.hasOutgoingRequest ? 'Заявка отправлена' :
|
||||
organization.hasIncomingRequest ? 'Уже подал заявку' :
|
||||
actionButtonText}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
|
432
src/components/messenger/messenger-chat.tsx
Normal file
432
src/components/messenger/messenger-chat.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { GET_MESSAGES } from '@/graphql/queries'
|
||||
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { EmojiPickerComponent } from '@/components/ui/emoji-picker'
|
||||
import { VoiceRecorder } from '@/components/ui/voice-recorder'
|
||||
import { VoicePlayer } from '@/components/ui/voice-player'
|
||||
import { FileUploader } from '@/components/ui/file-uploader'
|
||||
import { ImageMessage } from '@/components/ui/image-message'
|
||||
import { FileMessage } from '@/components/ui/file-message'
|
||||
import { Send, MoreVertical } from 'lucide-react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string
|
||||
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
content?: string
|
||||
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE' | null
|
||||
voiceUrl?: string
|
||||
voiceDuration?: number
|
||||
fileUrl?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileType?: string
|
||||
senderId: string
|
||||
senderOrganization: Organization
|
||||
receiverOrganization: Organization
|
||||
createdAt: string
|
||||
isRead: boolean
|
||||
}
|
||||
|
||||
interface MessengerChatProps {
|
||||
counterparty: Organization
|
||||
}
|
||||
|
||||
export function MessengerChat({ counterparty }: MessengerChatProps) {
|
||||
const { user } = useAuth()
|
||||
const [message, setMessage] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Загружаем сообщения с контрагентом
|
||||
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||
variables: { counterpartyId: counterparty.id },
|
||||
pollInterval: 3000, // Обновляем каждые 3 секунды для получения новых сообщений
|
||||
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные
|
||||
errorPolicy: 'all' // Показываем данные даже при ошибках
|
||||
})
|
||||
|
||||
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
|
||||
onCompleted: () => {
|
||||
refetch() // Перезагружаем сообщения после отправки
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка отправки сообщения:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
|
||||
onCompleted: () => {
|
||||
refetch() // Перезагружаем сообщения после отправки
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка отправки голосового сообщения:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка отправки изображения:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Ошибка отправки файла:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const messages = messagesData?.messages || []
|
||||
|
||||
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const getOrganizationName = (org: Organization) => {
|
||||
return org.name || org.fullName || 'Организация'
|
||||
}
|
||||
|
||||
const getManagerName = (org: Organization) => {
|
||||
return org.users?.[0]?.managerName || 'Управляющий'
|
||||
}
|
||||
|
||||
const getInitials = (org: Organization) => {
|
||||
const name = getOrganizationName(org)
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT':
|
||||
return 'Фулфилмент'
|
||||
case 'SELLER':
|
||||
return 'Селлер'
|
||||
case 'LOGIST':
|
||||
return 'Логистика'
|
||||
case 'WHOLESALE':
|
||||
return 'Оптовик'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT':
|
||||
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||
case 'SELLER':
|
||||
return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
case 'LOGIST':
|
||||
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
|
||||
case 'WHOLESALE':
|
||||
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!message.trim()) return
|
||||
|
||||
try {
|
||||
await sendMessageMutation({
|
||||
variables: {
|
||||
receiverOrganizationId: counterparty.id,
|
||||
content: message.trim()
|
||||
}
|
||||
})
|
||||
setMessage('')
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки сообщения:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
setMessage(prev => prev + emoji)
|
||||
}
|
||||
|
||||
const handleSendVoice = async (audioUrl: string, duration: number) => {
|
||||
try {
|
||||
await sendVoiceMessageMutation({
|
||||
variables: {
|
||||
receiverOrganizationId: counterparty.id,
|
||||
voiceUrl: audioUrl,
|
||||
voiceDuration: duration
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки голосового сообщения:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendFile = async (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => {
|
||||
try {
|
||||
if (messageType === 'IMAGE') {
|
||||
await sendImageMessageMutation({
|
||||
variables: {
|
||||
receiverOrganizationId: counterparty.id,
|
||||
fileUrl,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileType
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await sendFileMessageMutation({
|
||||
variables: {
|
||||
receiverOrganizationId: counterparty.id,
|
||||
fileUrl,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileType
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки файла:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
if (isNaN(date.getTime())) {
|
||||
return '00:00'
|
||||
}
|
||||
return date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Неизвестная дата'
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Сегодня'
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Вчера'
|
||||
} else {
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Заголовок чата */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
{counterparty.users?.[0]?.avatar ? (
|
||||
<AvatarImage
|
||||
src={counterparty.users[0].avatar}
|
||||
alt="Аватар организации"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||
{getInitials(counterparty)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<h3 className="text-white font-medium">
|
||||
{getOrganizationName(counterparty)}
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm mb-1">
|
||||
{getManagerName(counterparty)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
|
||||
{getTypeLabel(counterparty.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" className="text-white/60 hover:text-white">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Область сообщений */}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-white/60">Загрузка сообщений...</div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Send className="h-6 w-6 text-white/40" />
|
||||
</div>
|
||||
<p className="text-white/60 text-sm mb-1">Начните беседу</p>
|
||||
<p className="text-white/40 text-xs">
|
||||
Отправьте первое сообщение {getOrganizationName(counterparty)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg: Message, index: number) => {
|
||||
const isCurrentUser = msg.senderOrganization?.id === user?.organization?.id
|
||||
const showDate = index === 0 ||
|
||||
formatDate(messages[index - 1].createdAt) !== formatDate(msg.createdAt)
|
||||
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showDate && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-white/10 rounded-full px-3 py-1">
|
||||
<span className="text-white/60 text-xs">
|
||||
{formatDate(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||
<div className="flex flex-col max-w-xs lg:max-w-md">
|
||||
{/* Имя отправителя */}
|
||||
{!isCurrentUser && (
|
||||
<div className="flex items-center space-x-2 mb-1 px-1">
|
||||
<Avatar className="h-6 w-6">
|
||||
{msg.senderOrganization?.users?.[0]?.avatar ? (
|
||||
<AvatarImage
|
||||
src={msg.senderOrganization.users[0].avatar}
|
||||
alt="Аватар отправителя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-purple-500 text-white text-xs">
|
||||
{getInitials(msg.senderOrganization)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-white/60 text-xs">
|
||||
{getManagerName(msg.senderOrganization)}
|
||||
</span>
|
||||
<Badge className={`${getTypeColor(msg.senderOrganization.type)} text-xs`}>
|
||||
{getTypeLabel(msg.senderOrganization.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сообщение */}
|
||||
<div className="space-y-2">
|
||||
{msg.type === 'VOICE' && msg.voiceUrl ? (
|
||||
<VoicePlayer
|
||||
audioUrl={msg.voiceUrl}
|
||||
duration={msg.voiceDuration || 0}
|
||||
isCurrentUser={isCurrentUser}
|
||||
/>
|
||||
) : msg.type === 'IMAGE' && msg.fileUrl ? (
|
||||
<ImageMessage
|
||||
imageUrl={msg.fileUrl}
|
||||
fileName={msg.fileName || 'image'}
|
||||
fileSize={msg.fileSize}
|
||||
isCurrentUser={isCurrentUser}
|
||||
/>
|
||||
) : msg.type === 'FILE' && msg.fileUrl ? (
|
||||
<FileMessage
|
||||
fileUrl={msg.fileUrl}
|
||||
fileName={msg.fileName || 'file'}
|
||||
fileSize={msg.fileSize}
|
||||
fileType={msg.fileType}
|
||||
isCurrentUser={isCurrentUser}
|
||||
/>
|
||||
) : msg.content ? (
|
||||
<div className={`px-4 py-2 rounded-lg ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 text-white border border-blue-500/30'
|
||||
: 'bg-white/10 text-white border border-white/20'
|
||||
}`}>
|
||||
<p className="text-sm leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/50'
|
||||
}`}>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Поле ввода сообщения */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex flex-1 space-x-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Введите сообщение..."
|
||||
className="glass-input text-white placeholder:text-white/40 flex-1"
|
||||
/>
|
||||
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
|
||||
<FileUploader onSendFile={handleSendFile} />
|
||||
<VoiceRecorder onSendVoice={handleSendVoice} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!message.trim()}
|
||||
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer"
|
||||
variant="outline"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
178
src/components/messenger/messenger-conversations.tsx
Normal file
178
src/components/messenger/messenger-conversations.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Users, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
managementName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface MessengerConversationsProps {
|
||||
counterparties: Organization[]
|
||||
loading: boolean
|
||||
selectedCounterparty: string | null
|
||||
onSelectCounterparty: (counterpartyId: string) => void
|
||||
}
|
||||
|
||||
export function MessengerConversations({
|
||||
counterparties,
|
||||
loading,
|
||||
selectedCounterparty,
|
||||
onSelectCounterparty
|
||||
}: MessengerConversationsProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const getOrganizationName = (org: Organization) => {
|
||||
return org.name || org.fullName || 'Организация'
|
||||
}
|
||||
|
||||
const getManagerName = (org: Organization) => {
|
||||
return org.users?.[0]?.managerName || 'Управляющий'
|
||||
}
|
||||
|
||||
const getInitials = (org: Organization) => {
|
||||
const name = getOrganizationName(org)
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT':
|
||||
return 'Фулфилмент'
|
||||
case 'SELLER':
|
||||
return 'Селлер'
|
||||
case 'LOGIST':
|
||||
return 'Логистика'
|
||||
case 'WHOLESALE':
|
||||
return 'Оптовик'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT':
|
||||
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||
case 'SELLER':
|
||||
return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
case 'LOGIST':
|
||||
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
|
||||
case 'WHOLESALE':
|
||||
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCounterparties = counterparties.filter(org => {
|
||||
if (!searchTerm) return true
|
||||
const name = getOrganizationName(org).toLowerCase()
|
||||
const managerName = getManagerName(org).toLowerCase()
|
||||
const inn = org.inn.toLowerCase()
|
||||
const search = searchTerm.toLowerCase()
|
||||
return name.includes(search) || inn.includes(search) || managerName.includes(search)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Загрузка...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Users className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
||||
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск по названию или ИНН..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="glass-input text-white placeholder:text-white/40 pl-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Список контрагентов */}
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{filteredCounterparties.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Users className="h-6 w-6 text-white/40" />
|
||||
</div>
|
||||
<p className="text-white/60 text-sm">
|
||||
{searchTerm ? 'Ничего не найдено' : 'Контрагенты не найдены'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredCounterparties.map((org) => (
|
||||
<div
|
||||
key={org.id}
|
||||
onClick={() => onSelectCounterparty(org.id)}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 ${
|
||||
selectedCounterparty === org.id
|
||||
? 'bg-white/20 border border-white/30'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||
{org.users?.[0]?.avatar ? (
|
||||
<AvatarImage
|
||||
src={org.users[0].avatar}
|
||||
alt="Аватар организации"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||
{getInitials(org)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium text-sm leading-tight truncate mb-1">
|
||||
{getOrganizationName(org)}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Badge className={`${getTypeColor(org.type)} text-xs`}>
|
||||
{getTypeLabel(org.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-white/60 text-xs truncate">
|
||||
{getManagerName(org)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
113
src/components/messenger/messenger-dashboard.tsx
Normal file
113
src/components/messenger/messenger-dashboard.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { MessengerConversations } from './messenger-conversations'
|
||||
import { MessengerChat } from './messenger-chat'
|
||||
import { MessengerEmptyState } from './messenger-empty-state'
|
||||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function MessengerDashboard() {
|
||||
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
||||
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||||
const counterparties = counterpartiesData?.myCounterparties || []
|
||||
|
||||
const handleSelectCounterparty = (counterpartyId: string) => {
|
||||
setSelectedCounterparty(counterpartyId)
|
||||
}
|
||||
|
||||
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
|
||||
|
||||
// Если нет контрагентов, показываем заглушку
|
||||
if (!counterpartiesLoading && counterparties.length === 0) {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
|
||||
<p className="text-white/70 text-sm">Общение с контрагентами</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MessengerEmptyState />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок - фиксированная высота */}
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
|
||||
<p className="text-white/70 text-sm">Общение с контрагентами</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент - сетка из 2 колонок */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="grid grid-cols-[350px_1fr] gap-4 h-full">
|
||||
{/* Левая колонка - список контрагентов */}
|
||||
<Card className="glass-card h-full overflow-hidden p-4">
|
||||
<MessengerConversations
|
||||
counterparties={counterparties}
|
||||
loading={counterpartiesLoading}
|
||||
selectedCounterparty={selectedCounterparty}
|
||||
onSelectCounterparty={handleSelectCounterparty}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Правая колонка - чат */}
|
||||
<Card className="glass-card h-full overflow-hidden">
|
||||
{selectedCounterparty && selectedCounterpartyData ? (
|
||||
<MessengerChat counterparty={selectedCounterpartyData} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<MessageCircle className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
<p className="text-white/60 text-lg mb-2">Выберите контрагента</p>
|
||||
<p className="text-white/40 text-sm">
|
||||
Начните беседу с одним из ваших контрагентов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
48
src/components/messenger/messenger-empty-state.tsx
Normal file
48
src/components/messenger/messenger-empty-state.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MessageCircle, Store, Users } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function MessengerEmptyState() {
|
||||
const router = useRouter()
|
||||
|
||||
const handleGoToMarket = () => {
|
||||
router.push('/market')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<MessageCircle className="h-10 w-10 text-white/40" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
У вас пока нет контрагентов
|
||||
</h3>
|
||||
|
||||
<p className="text-white/60 text-sm mb-6 leading-relaxed">
|
||||
Чтобы начать общение, сначала найдите и добавьте контрагентов в разделе «Маркет».
|
||||
После добавления они появятся здесь для общения.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleGoToMarket}
|
||||
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 w-full cursor-pointer"
|
||||
variant="outline"
|
||||
>
|
||||
<Store className="h-4 w-4 mr-2" />
|
||||
Перейти в Маркет
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center text-white/40 text-xs">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Найдите партнеров и начните общение
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
68
src/components/ui/emoji-picker.tsx
Normal file
68
src/components/ui/emoji-picker.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Smile } from 'lucide-react'
|
||||
|
||||
// Динамически импортируем EmojiPicker чтобы избежать проблем с SSR
|
||||
const EmojiPicker = dynamic(
|
||||
() => import('emoji-picker-react'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
interface EmojiPickerComponentProps {
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
}
|
||||
|
||||
export function EmojiPickerComponent({ onEmojiSelect }: EmojiPickerComponentProps) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
|
||||
setShowPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showPicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showPicker])
|
||||
|
||||
const handleEmojiClick = (emojiData: { emoji: string }) => {
|
||||
onEmojiSelect(emojiData.emoji)
|
||||
setShowPicker(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={pickerRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Smile className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{showPicker && (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||
<div className="bg-gray-800 border border-white/20 rounded-lg overflow-hidden shadow-xl">
|
||||
<EmojiPicker
|
||||
onEmojiClick={handleEmojiClick}
|
||||
width={350}
|
||||
height={400}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
108
src/components/ui/file-message.tsx
Normal file
108
src/components/ui/file-message.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { Download, FileText, Archive, Image as ImageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface FileMessageProps {
|
||||
fileUrl: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
fileType?: string
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export function FileMessage({ fileUrl, fileName, fileSize, fileType, isCurrentUser = false }: FileMessageProps) {
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileText className="h-6 w-6" />
|
||||
|
||||
if (type.includes('pdf')) return <FileText className="h-6 w-6 text-red-400" />
|
||||
if (type.includes('zip') || type.includes('archive')) return <Archive className="h-6 w-6 text-yellow-400" />
|
||||
if (type.includes('image')) return <ImageIcon className="h-6 w-6 text-green-400" />
|
||||
if (type.includes('word') || type.includes('document')) return <FileText className="h-6 w-6 text-blue-400" />
|
||||
if (type.includes('excel') || type.includes('spreadsheet')) return <FileText className="h-6 w-6 text-green-600" />
|
||||
if (type.includes('powerpoint') || type.includes('presentation')) return <FileText className="h-6 w-6 text-orange-400" />
|
||||
|
||||
return <FileText className="h-6 w-6" />
|
||||
}
|
||||
|
||||
const getFileExtension = (name: string) => {
|
||||
const parts = name.split('.')
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = fileUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-3 p-3 rounded-lg max-w-sm cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 border border-blue-500/30'
|
||||
: 'bg-white/10 border border-white/20'
|
||||
}`} onClick={handleDownload}>
|
||||
|
||||
{/* Иконка файла */}
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
isCurrentUser ? 'bg-blue-500/30' : 'bg-white/20'
|
||||
}`}>
|
||||
{getFileIcon(fileType)}
|
||||
</div>
|
||||
|
||||
{/* Информация о файле */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium text-sm truncate ${
|
||||
isCurrentUser ? 'text-white' : 'text-white'
|
||||
}`}>
|
||||
{fileName}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{fileSize && (
|
||||
<span className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||
}`}>
|
||||
{formatFileSize(fileSize)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||
}`}>
|
||||
{getFileExtension(fileName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка скачивания */}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDownload()
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`flex-shrink-0 ${
|
||||
isCurrentUser
|
||||
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
216
src/components/ui/file-uploader.tsx
Normal file
216
src/components/ui/file-uploader.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Paperclip, Image, X } from 'lucide-react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
interface FileUploaderProps {
|
||||
onSendFile: (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => void
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
url: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
messageType: 'IMAGE' | 'FILE'
|
||||
}
|
||||
|
||||
export function FileUploader({ onSendFile }: FileUploaderProps) {
|
||||
const { user } = useAuth()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<UploadedFile | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isImageType = (type: string) => {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleFileSelect = async (file: File, messageType: 'IMAGE' | 'FILE') => {
|
||||
if (!user?.id) return
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
formData.append('messageType', messageType)
|
||||
|
||||
const response = await fetch('/api/upload-file', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to upload file'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.error || errorMessage
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Upload failed')
|
||||
}
|
||||
|
||||
setSelectedFile({
|
||||
url: result.url,
|
||||
name: result.originalName,
|
||||
size: result.size,
|
||||
type: result.type,
|
||||
messageType: result.messageType
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||
alert(`Ошибка при загрузке файла: ${errorMessage}`)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>, messageType: 'IMAGE' | 'FILE') => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
handleFileSelect(file, messageType)
|
||||
}
|
||||
// Очищаем input для возможности выбора того же файла
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleSendFile = () => {
|
||||
if (selectedFile) {
|
||||
onSendFile(
|
||||
selectedFile.url,
|
||||
selectedFile.name,
|
||||
selectedFile.size,
|
||||
selectedFile.type,
|
||||
selectedFile.messageType
|
||||
)
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelFile = () => {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
|
||||
if (selectedFile) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2 bg-white/10 rounded-lg p-3 border border-white/20">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
{isImageType(selectedFile.type) ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={selectedFile.url}
|
||||
alt="Preview"
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-10 h-10 bg-blue-500/20 rounded flex items-center justify-center">
|
||||
<Paperclip className="h-5 w-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
onClick={handleSendFile}
|
||||
size="sm"
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30"
|
||||
variant="outline"
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelFile}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Кнопка для загрузки изображений */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Image className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Кнопка для загрузки файлов */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Скрытые input элементы */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFileInputChange(e, 'IMAGE')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.json"
|
||||
onChange={(e) => handleFileInputChange(e, 'FILE')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<div className="text-white/60 text-xs">
|
||||
Загрузка...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
129
src/components/ui/image-message.tsx
Normal file
129
src/components/ui/image-message.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download, Eye } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ImageMessageProps {
|
||||
imageUrl: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = false }: ImageMessageProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showFullSize, setShowFullSize] = useState(false)
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = imageUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const handleImageClick = () => {
|
||||
setShowFullSize(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative group max-w-xs ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 border border-blue-500/30'
|
||||
: 'bg-white/10 border border-white/20'
|
||||
} rounded-lg overflow-hidden`}>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={fileName}
|
||||
className="w-full h-auto cursor-pointer transition-opacity duration-200"
|
||||
style={{
|
||||
opacity: isLoading ? 0 : 1,
|
||||
maxHeight: '300px',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-gray-700 animate-pulse flex items-center justify-center">
|
||||
<div className="text-white/60 text-sm">Загрузка...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay с кнопками при наведении */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
onClick={handleImageClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о файле */}
|
||||
<div className="p-2">
|
||||
<p className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||
} truncate`}>
|
||||
{fileName}
|
||||
</p>
|
||||
{fileSize && (
|
||||
<p className={`text-xs ${
|
||||
isCurrentUser ? 'text-blue-300/50' : 'text-white/40'
|
||||
}`}>
|
||||
{formatFileSize(fileSize)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Полноэкранный просмотр */}
|
||||
{showFullSize && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setShowFullSize(false)}
|
||||
>
|
||||
<div className="relative max-w-full max-h-full">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={fileName}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowFullSize(false)}
|
||||
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white"
|
||||
size="sm"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
174
src/components/ui/voice-player.tsx
Normal file
174
src/components/ui/voice-player.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play, Pause, Volume2 } from 'lucide-react'
|
||||
|
||||
interface VoicePlayerProps {
|
||||
audioUrl: string
|
||||
duration?: number
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export function VoicePlayer({ audioUrl, duration = 0, isCurrentUser = false }: VoicePlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [audioDuration, setAudioDuration] = useState(duration > 0 ? duration : 0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// Обновляем длительность при изменении props
|
||||
useEffect(() => {
|
||||
if (duration > 0 && (!audioDuration || audioDuration === 0)) {
|
||||
setAudioDuration(duration)
|
||||
}
|
||||
}, [duration, audioDuration])
|
||||
|
||||
useEffect(() => {
|
||||
// Создаем аудио элемент
|
||||
audioRef.current = new Audio(audioUrl)
|
||||
const audio = audioRef.current
|
||||
|
||||
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audio.duration && isFinite(audio.duration) && !isNaN(audio.duration)) {
|
||||
setAudioDuration(audio.duration)
|
||||
} else {
|
||||
setAudioDuration(duration || 0)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (audio.currentTime && isFinite(audio.currentTime) && !isNaN(audio.currentTime)) {
|
||||
setCurrentTime(audio.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleLoadStart = () => {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
console.error('Audio loading error')
|
||||
setIsLoading(false)
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
audio.addEventListener('canplay', handleCanPlay)
|
||||
audio.addEventListener('loadstart', handleLoadStart)
|
||||
audio.addEventListener('error', handleError)
|
||||
|
||||
return () => {
|
||||
if (audio) {
|
||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
audio.removeEventListener('canplay', handleCanPlay)
|
||||
audio.removeEventListener('loadstart', handleLoadStart)
|
||||
audio.removeEventListener('error', handleError)
|
||||
audio.pause()
|
||||
}
|
||||
}
|
||||
}, [audioUrl])
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
setIsPlaying(true)
|
||||
}).catch((error) => {
|
||||
console.error('Error playing audio:', error)
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time) || !isFinite(time) || time < 0) return '0:00'
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getProgress = () => {
|
||||
if (!audioDuration || audioDuration === 0 || isNaN(audioDuration) || !isFinite(audioDuration)) return 0
|
||||
if (!currentTime || isNaN(currentTime) || !isFinite(currentTime)) return 0
|
||||
return Math.min((currentTime / audioDuration) * 100, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-4 p-3 rounded-lg min-w-[200px] max-w-sm ${
|
||||
isCurrentUser
|
||||
? 'bg-blue-500/20 border border-blue-500/30'
|
||||
: 'bg-white/10 border border-white/20'
|
||||
}`}>
|
||||
{/* Кнопка воспроизведения */}
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
disabled={isLoading}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`p-2 rounded-full ${
|
||||
isCurrentUser
|
||||
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
|
||||
: 'text-white hover:text-white/80 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Визуализация волны / прогресс бар */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Volume2 className={`h-3 w-3 ${
|
||||
isCurrentUser ? 'text-blue-300' : 'text-white/60'
|
||||
}`} />
|
||||
<div className="flex-1 h-1 bg-white/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isCurrentUser ? 'bg-blue-400' : 'bg-white/60'
|
||||
}`}
|
||||
style={{ width: `${getProgress()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Время */}
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem]`}>
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem] text-right`}>
|
||||
{formatTime(audioDuration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
254
src/components/ui/voice-recorder.tsx
Normal file
254
src/components/ui/voice-recorder.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Mic, MicOff, Square, Send, Trash2 } from 'lucide-react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSendVoice: (audioUrl: string, duration: number) => void
|
||||
}
|
||||
|
||||
export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
|
||||
const { user } = useAuth()
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordedAudio, setRecordedAudio] = useState<string | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt')
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем доступность микрофона
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
|
||||
navigator.permissions.query({ name: 'microphone' as PermissionName }).then((result) => {
|
||||
setPermission(result.state as 'granted' | 'denied' | 'prompt')
|
||||
}).catch(() => {
|
||||
// Если permissions API недоступен, оставляем prompt
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
setPermission('granted')
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||||
const audioUrl = URL.createObjectURL(audioBlob)
|
||||
setRecordedAudio(audioUrl)
|
||||
|
||||
// Останавливаем все треки для освобождения микрофона
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
|
||||
// Останавливаем таймер
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setDuration(0)
|
||||
|
||||
// Запускаем таймер записи
|
||||
intervalRef.current = setInterval(() => {
|
||||
setDuration(prev => prev + 1)
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error)
|
||||
setPermission('denied')
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
setRecordedAudio(null)
|
||||
setDuration(0)
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const playRecording = () => {
|
||||
if (recordedAudio) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
|
||||
audioRef.current = new Audio(recordedAudio)
|
||||
audioRef.current.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
audioRef.current.onended = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendVoiceMessage = async () => {
|
||||
if (!recordedAudio || !user?.id) return
|
||||
|
||||
try {
|
||||
// Конвертируем Blob в File для загрузки
|
||||
const response = await fetch(recordedAudio)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
|
||||
|
||||
// Загружаем в S3
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
|
||||
const uploadResponse = await fetch('/api/upload-voice', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload voice message')
|
||||
}
|
||||
|
||||
const result = await uploadResponse.json()
|
||||
|
||||
// Отправляем голосовое сообщение
|
||||
onSendVoice(result.url, duration)
|
||||
|
||||
// Очищаем состояние
|
||||
setRecordedAudio(null)
|
||||
setDuration(0)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending voice message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
return (
|
||||
<div className="text-white/60 text-xs text-center p-2">
|
||||
Доступ к микрофону запрещен
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!recordedAudio ? (
|
||||
// Состояние записи
|
||||
<>
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={startRecording}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 bg-red-500/20 rounded-lg px-3 py-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
<span className="text-white text-sm font-mono">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={stopRecording}
|
||||
className="text-white/80 hover:text-white p-1"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelRecording}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Состояние воспроизведения и отправки
|
||||
<div className="flex items-center space-x-2 bg-blue-500/20 rounded-lg px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={playRecording}
|
||||
className="text-blue-300 hover:text-blue-200 p-1"
|
||||
>
|
||||
{isPlaying ? <MicOff className="h-3 w-3" /> : <Mic className="h-3 w-3" />}
|
||||
</Button>
|
||||
<span className="text-white text-sm font-mono">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={sendVoiceMessage}
|
||||
className="text-green-400 hover:text-green-300 p-1"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecordedAudio(null)}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user