diff --git a/apps/api-gateway/src/controllers/admin.controller.ts b/apps/api-gateway/src/controllers/admin.controller.ts index 623d220..fab6726 100644 --- a/apps/api-gateway/src/controllers/admin.controller.ts +++ b/apps/api-gateway/src/controllers/admin.controller.ts @@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs import { map } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { CurrentAdmin } from '../decorators/current-admin.decorator'; -import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto'; +import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, SetUserVerificationDto, UpdateUserDto } from '../dto/admin.dto'; import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; @ApiTags('Администрирование') @@ -32,6 +32,15 @@ export class AdminController { ); } + @Get('verification-icons') + @ApiOperation({ summary: 'Список значков верификации', description: 'Доступные значки для выбора при верификации пользователя.' }) + listVerificationIcons(@CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canVerifyUsers) { + throw new ForbiddenException('Недостаточно прав для верификации пользователей'); + } + return this.core.admin.ListVerificationIcons({}); + } + @Patch(':userId') @ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) @@ -66,4 +75,20 @@ export class AdminController { setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) { return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin }); } + + @Patch(':userId/verification') + @ApiOperation({ summary: 'Верифицировать или снять верификацию', description: 'Требуется право users.verify.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: SetUserVerificationDto }) + setUserVerification(@Param('userId') userId: string, @Body() dto: SetUserVerificationDto, @CurrentAdmin() admin: AdminRequestUser) { + if (!admin.canVerifyUsers) { + throw new ForbiddenException('Недостаточно прав для верификации пользователей'); + } + return this.core.admin.SetUserVerification({ + actorUserId: admin.id, + userId, + isVerified: dto.isVerified, + verificationIcon: dto.verificationIcon + }); + } } diff --git a/apps/api-gateway/src/dto/admin.dto.ts b/apps/api-gateway/src/dto/admin.dto.ts index 7498c4d..a35face 100644 --- a/apps/api-gateway/src/dto/admin.dto.ts +++ b/apps/api-gateway/src/dto/admin.dto.ts @@ -58,3 +58,14 @@ export class SetSuperAdminDto { @IsBoolean({ message: 'isSuperAdmin должно быть boolean' }) isSuperAdmin!: boolean; } + +export class SetUserVerificationDto { + @ApiProperty({ description: 'Верифицировать или снять верификацию' }) + @IsBoolean({ message: 'isVerified должно быть boolean' }) + isVerified!: boolean; + + @ApiPropertyOptional({ description: 'Slug значка из списка (badge-check, star, moon и т.д.)' }) + @IsOptional() + @IsString({ message: 'Значок должен быть строкой' }) + verificationIcon?: string; +} diff --git a/apps/api-gateway/src/guards/admin.guard.ts b/apps/api-gateway/src/guards/admin.guard.ts index 44ac470..66620ee 100644 --- a/apps/api-gateway/src/guards/admin.guard.ts +++ b/apps/api-gateway/src/guards/admin.guard.ts @@ -16,6 +16,7 @@ export interface AdminRequestUser { canManageAllUsers: boolean; canViewUsers: boolean; canManageSettings: boolean; + canVerifyUsers: boolean; roles: string[]; permissions: string[]; } @@ -58,6 +59,7 @@ export class AdminGuard implements CanActivate { canManageAllUsers: Boolean(profile.canManageAllUsers), canManageSettings: Boolean(profile.canManageSettings), canViewUsers: Boolean(profile.canViewUsers), + canVerifyUsers: Boolean(profile.canVerifyUsers), roles: profile.roles ?? [], permissions: profile.permissions ?? [] }; diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index 6f1b7bf..6a18ef1 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; +import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; +import { VerificationBadge } from '@/components/id/verification-badge'; import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog'; import { AdminShell } from '@/components/id/admin-shell'; import { useAuth } from '@/components/id/auth-provider'; @@ -13,6 +14,7 @@ import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { AdminPermission, AdminRole, AdminUser, apiFetch } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; +import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons'; const statusLabels: Record = { ACTIVE: 'Активен', @@ -42,8 +44,9 @@ export default function AdminUsersPage() { const [selectedUser, setSelectedUser] = useState(null); const [password, setPassword] = useState(''); const [actionLoading, setActionLoading] = useState(false); - const [dialog, setDialog] = useState<'password' | 'roles' | null>(null); + const [dialog, setDialog] = useState<'password' | 'roles' | 'verification' | null>(null); const [documentsUser, setDocumentsUser] = useState(null); + const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON); const loadUsers = useCallback(async () => { if (!token) return; @@ -224,6 +227,53 @@ export default function AdminUsersPage() { } } + async function handleSaveVerification() { + if (!token || !selectedUser) return; + setActionLoading(true); + try { + const updated = await apiFetch(`/admin/users/${selectedUser.id}/verification`, { + method: 'PATCH', + body: JSON.stringify({ + isVerified: true, + verificationIcon + }) + }, token); + showToast('Пользователь верифицирован'); + setSelectedUser(updated); + setDialog(null); + await loadUsers(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось верифицировать пользователя'); + } finally { + setActionLoading(false); + } + } + + async function handleRemoveVerification() { + if (!token || !selectedUser) return; + setActionLoading(true); + try { + await apiFetch(`/admin/users/${selectedUser.id}/verification`, { + method: 'PATCH', + body: JSON.stringify({ isVerified: false }) + }, token); + showToast('Верификация снята'); + setSelectedUser({ ...selectedUser, isVerified: false, verificationIcon: undefined }); + setDialog(null); + await loadUsers(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось снять верификацию'); + } finally { + setActionLoading(false); + } + } + + function openVerificationDialog(user: AdminUser) { + setSelectedUser(user); + setVerificationIcon(user.verificationIcon ?? DEFAULT_VERIFICATION_ICON); + setDialog('verification'); + } + function renderRoles(user: AdminUser) { const userRoles = user.roles ?? []; const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE); @@ -271,7 +321,10 @@ export default function AdminUsersPage() { {users.map((user) => ( -
{user.displayName}
+
+ {user.displayName} + {user.isVerified ? : null} +
{user.email ?? user.username ?? '—'}
{user.phone ?? '—'} @@ -315,6 +368,17 @@ export default function AdminUsersPage() { ) : null} + {currentUser?.canVerifyUsers ? ( + + ) : null} {currentUser?.canManageRoles ? ( <> + ))} + + +
+ + {selectedUser?.isVerified ? ( + + ) : null} +
+ + + {documentsUser ? (
{user ? ( - + ) : ( )} diff --git a/apps/frontend/app/family/[groupId]/page.tsx b/apps/frontend/app/family/[groupId]/page.tsx index c0f080c..30f1dd7 100644 --- a/apps/frontend/app/family/[groupId]/page.tsx +++ b/apps/frontend/app/family/[groupId]/page.tsx @@ -1,11 +1,18 @@ 'use client'; -import { use } from 'react'; -import { IdShell } from '@/components/id/shell'; +import { use, useEffect } from 'react'; import { FamilyGroupView } from '@/components/family/family-group-view'; +import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; +import { IdShell } from '@/components/id/shell'; export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) { const { groupId } = use(params); + const { setSelectedGroupId } = useFamilyOverlay(); + + useEffect(() => { + setSelectedGroupId(groupId); + }, [groupId, setSelectedGroupId]); + return ( diff --git a/apps/frontend/app/family/page.tsx b/apps/frontend/app/family/page.tsx index d732e8a..a8d6a47 100644 --- a/apps/frontend/app/family/page.tsx +++ b/apps/frontend/app/family/page.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useRequireAuth } from '@/hooks/use-require-auth'; import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api'; +import { defaultFamilyGroupName } from '@/lib/family-defaults'; export default function FamilyPage() { const router = useRouter(); @@ -17,9 +18,15 @@ export default function FamilyPage() { const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); const [groups, setGroups] = useState([]); - const [name, setName] = useState('Моя семья'); + const [name, setName] = useState(''); const [creating, setCreating] = useState(false); + useEffect(() => { + if (user?.displayName) { + setName(defaultFamilyGroupName(user.displayName)); + } + }, [user?.displayName]); + useEffect(() => { if (!user || !token || isPinLocked) return; fetchFamilyGroups(user.id, token) @@ -63,7 +70,7 @@ export default function FamilyPage() {
setName(event.target.value)} placeholder="Название семьи" /> -
diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index d0390b8..f1e18c1 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -7,7 +7,7 @@ import { AddressQuickSection } from '@/components/addresses/address-quick-sectio import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; import { ActionTile, SectionTitle } from '@/components/id/action-tile'; import { AdminBadge } from '@/components/id/admin-badge'; -import { AvatarUpload } from '@/components/id/avatar-upload'; +import { AvatarDisplay } from '@/components/id/avatar-upload'; import { useAuth } from '@/components/id/auth-provider'; import { IdShell } from '@/components/id/shell'; import { Button } from '@/components/ui/button'; @@ -17,7 +17,7 @@ import { apiFetch, UserDocument } from '@/lib/api'; export default function HomePage() { const router = useRouter(); - const { user, token, refreshProfile, isPinLocked } = useAuth(); + const { user, token, isPinLocked } = useAuth(); const { isReady } = useRequireAuth(); const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · '); const [documents, setDocuments] = useState([]); @@ -55,12 +55,15 @@ export default function HomePage() {
{user ? ( - ) : (
diff --git a/apps/frontend/components/family/avatar-with-presence.tsx b/apps/frontend/components/family/avatar-with-presence.tsx new file mode 100644 index 0000000..1091fc4 --- /dev/null +++ b/apps/frontend/components/family/avatar-with-presence.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { UserAvatar } from '@/components/id/user-avatar'; +import { cn } from '@/lib/utils'; + +export function OnlineDot({ online, className }: { online?: boolean; className?: string }) { + if (!online) return null; + return ( + + ); +} + +export function AvatarWithPresence({ + userId, + displayName, + hasAvatar, + token, + isVerified, + verificationIcon, + online, + className = 'h-9 w-9', + badgeSize = 'xs' +}: { + userId: string; + displayName?: string; + hasAvatar?: boolean; + token: string | null; + isVerified?: boolean; + verificationIcon?: string | null; + online?: boolean; + className?: string; + badgeSize?: 'xs' | 'sm' | 'md' | 'lg'; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/frontend/components/family/family-group-selector.tsx b/apps/frontend/components/family/family-group-selector.tsx new file mode 100644 index 0000000..952bf7c --- /dev/null +++ b/apps/frontend/components/family/family-group-selector.tsx @@ -0,0 +1,76 @@ +'use client'; + +import Link from 'next/link'; +import { ChevronDown } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +type FamilyGroupOption = { + id: string; + name: string; +}; + +export function FamilyGroupSelector({ + groups, + selectedGroupId, + onSelect, + href, + className, + nameClassName +}: { + groups: FamilyGroupOption[]; + selectedGroupId: string | null; + onSelect: (groupId: string) => void; + href?: string; + className?: string; + nameClassName?: string; +}) { + const selected = groups.find((group) => group.id === selectedGroupId) ?? groups[0]; + + if (!selected) return null; + + const nameElement = href ? ( + event.stopPropagation()}> + {selected.name} + + ) : ( + {selected.name} + ); + + if (groups.length <= 1) { + return
{nameElement}
; + } + + return ( + + + + + + {groups.map((group) => ( + onSelect(group.id)} + > + {group.name} + + ))} + + + ); +} diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index a352f92..f26a8f6 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -23,7 +23,10 @@ import { Volume2, VolumeX } from 'lucide-react'; +import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; +import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; import { VoiceMessagePlayer } from '@/components/chat/voice-message-player'; +import { UserAvatar } from '@/components/id/user-avatar'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; import { useRealtime } from '@/components/notifications/realtime-provider'; @@ -48,7 +51,6 @@ import { deleteFamilyGroup, editChatMessage, FamilyGroup, - FamilyInviteCandidate, FamilyPresenceMember, fetchChatMessages, fetchChatRooms, @@ -58,9 +60,7 @@ import { markChatRoomRead, removeChatRoomMember, removeFamilyMember, - searchFamilyInviteUsers, sendChatMessage, - sendFamilyInvite, setChatRoomMuted, updateFamilyGroup, uploadMediaObject, @@ -132,18 +132,6 @@ function messagePreview(message?: ChatMessage) { return message.content ?? ''; } -function OnlineDot({ online, className }: { online?: boolean; className?: string }) { - if (!online) return null; - return ( - - ); -} - function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null }) { const [url, setUrl] = useState(null); useEffect(() => { @@ -206,10 +194,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [membersOpen, setMembersOpen] = useState(false); const [familyMembersOpen, setFamilyMembersOpen] = useState(false); const [addMembersOpen, setAddMembersOpen] = useState(false); - const [inviteQuery, setInviteQuery] = useState(''); - const [inviteResults, setInviteResults] = useState([]); - const [selectedInviteUser, setSelectedInviteUser] = useState(null); - const [inviteSearching, setInviteSearching] = useState(false); const [renameValue, setRenameValue] = useState(''); const [newRoomName, setNewRoomName] = useState(''); const [selectedMembers, setSelectedMembers] = useState([]); @@ -579,39 +563,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } - async function submitInvite() { - if (!token || !selectedInviteUser) return; - try { - await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token); - setInviteOpen(false); - setInviteQuery(''); - setInviteResults([]); - setSelectedInviteUser(null); - showToast('Приглашение отправлено'); - } catch (error) { - showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка'); - } - } - - useEffect(() => { - if (!inviteOpen || !token || selectedInviteUser) { - return; - } - const query = inviteQuery.trim(); - if (query.length < 2) { - setInviteResults([]); - return; - } - const timer = window.setTimeout(() => { - setInviteSearching(true); - void searchFamilyInviteUsers(groupId, query, token) - .then((response) => setInviteResults(response.users ?? [])) - .catch(() => setInviteResults([])) - .finally(() => setInviteSearching(false)); - }, 300); - return () => window.clearTimeout(timer); - }, [groupId, inviteOpen, inviteQuery, selectedInviteUser, token]); - async function submitCreateRoom() { if (!token || !newRoomName.trim()) return; try { @@ -941,7 +892,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const isEditing = editingMessageId === message.id; const canEdit = mine && !message.isDeleted && (message.type === 'TEXT' || message.type === 'EMOJI'); return ( -
+
+ {!mine ? ( + + ) : null}
- { - setInviteOpen(open); - if (!open) { - setInviteQuery(''); - setInviteResults([]); - setSelectedInviteUser(null); - } - }}> - - Пригласить в семью - {selectedInviteUser ? ( -
-

{selectedInviteUser.displayName}

-

- {selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'} -

- -
- ) : ( - <> - setInviteQuery(event.target.value)} - /> -
- {inviteSearching ? ( -
- - Ищем пользователей... -
- ) : inviteQuery.trim().length < 2 ? ( -

Введите минимум 2 символа для поиска

- ) : inviteResults.length ? ( - inviteResults.map((candidate) => ( - - )) - ) : ( -

Пользователи не найдены

- )} -
- - )} - -
-
+ void loadGroup()} + /> @@ -1262,21 +1163,27 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { return (
-
- - {member.displayName.slice(0, 1)} - - -
+

{member.displayName} {isSelf ? (Вы) : null}

{familyMemberRoleLabel(member.role)}

-

- {presence ? formatPresenceStatus(presence) : 'Статус неизвестен'} -

+ {!isSelf ? ( +

+ {presence ? formatPresenceStatus(presence) : 'Статус неизвестен'} +

+ ) : null}
{canRemove ? ( - )) + ); + }) ) : (

Все участники семьи уже в этом чате

)} diff --git a/apps/frontend/components/family/family-invite-dialog.tsx b/apps/frontend/components/family/family-invite-dialog.tsx new file mode 100644 index 0000000..83c6739 --- /dev/null +++ b/apps/frontend/components/family/family-invite-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; +import { useAuth } from '@/components/id/auth-provider'; +import { useToast } from '@/components/id/toast-provider'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { FamilyInviteCandidate, getApiErrorMessage, searchFamilyInviteUsers, sendFamilyInvite } from '@/lib/api'; + +export function FamilyInviteDialog({ + groupId, + open, + onOpenChange, + onInvited +}: { + groupId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onInvited?: () => void; +}) { + const { token } = useAuth(); + const { showToast } = useToast(); + const [inviteQuery, setInviteQuery] = useState(''); + const [inviteResults, setInviteResults] = useState([]); + const [inviteSearching, setInviteSearching] = useState(false); + const [selectedInviteUser, setSelectedInviteUser] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) { + setInviteQuery(''); + setInviteResults([]); + setSelectedInviteUser(null); + return; + } + if (!token || inviteQuery.trim().length < 2) { + setInviteResults([]); + return; + } + + const timer = window.setTimeout(() => { + setInviteSearching(true); + searchFamilyInviteUsers(groupId, inviteQuery.trim(), token) + .then((response) => setInviteResults(response.users ?? [])) + .catch(() => setInviteResults([])) + .finally(() => setInviteSearching(false)); + }, 300); + + return () => window.clearTimeout(timer); + }, [groupId, inviteQuery, open, token]); + + async function submitInvite() { + if (!token || !selectedInviteUser) return; + setSubmitting(true); + try { + await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token); + showToast('Приглашение отправлено'); + onOpenChange(false); + onInvited?.(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка'); + } finally { + setSubmitting(false); + } + } + + return ( + + + + Пригласить в семью + + {selectedInviteUser ? ( +
+

{selectedInviteUser.displayName}

+

+ {selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'} +

+ +
+ ) : ( + <> + setInviteQuery(event.target.value)} + /> +
+ {inviteSearching ? ( +
+ + Ищем пользователей... +
+ ) : inviteQuery.trim().length < 2 ? ( +

Введите минимум 2 символа для поиска

+ ) : inviteResults.length ? ( + inviteResults.map((candidate) => ( + + )) + ) : ( +

Пользователи не найдены

+ )} +
+ + )} + +
+
+ ); +} diff --git a/apps/frontend/components/family/family-overlay-provider.tsx b/apps/frontend/components/family/family-overlay-provider.tsx new file mode 100644 index 0000000..3abc3a7 --- /dev/null +++ b/apps/frontend/components/family/family-overlay-provider.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + +const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id'; + +type FamilyOverlayContextValue = { + openMiniChat: () => void; + openChatRoom: (roomId: string) => void; + openChatWithMember: (memberUserId: string, memberName: string) => void; + closeMiniChat: () => void; + miniChatOpen: boolean; + pendingRoomId: string | null; + pendingMember: { userId: string; name: string } | null; + clearPending: () => void; + selectedGroupId: string | null; + setSelectedGroupId: (groupId: string | null) => void; +}; + +const FamilyOverlayContext = createContext(null); + +export function FamilyOverlayProvider({ children }: { children: React.ReactNode }) { + const [miniChatOpen, setMiniChatOpen] = useState(false); + const [pendingRoomId, setPendingRoomId] = useState(null); + const [pendingMember, setPendingMember] = useState<{ userId: string; name: string } | null>(null); + const [selectedGroupId, setSelectedGroupIdState] = useState(null); + const pendingMemberRef = useRef<{ userId: string; name: string } | null>(null); + const selectionHydratedRef = useRef(false); + + useEffect(() => { + if (selectionHydratedRef.current) return; + selectionHydratedRef.current = true; + const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY); + if (stored) setSelectedGroupIdState(stored); + }, []); + + const setSelectedGroupId = useCallback((groupId: string | null) => { + setSelectedGroupIdState(groupId); + if (groupId) { + window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId); + } else { + window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY); + } + }, []); + + const openMiniChat = useCallback(() => { + setMiniChatOpen(true); + }, []); + + const closeMiniChat = useCallback(() => { + setMiniChatOpen(false); + }, []); + + const openChatRoom = useCallback((roomId: string) => { + setPendingRoomId(roomId); + setPendingMember(null); + pendingMemberRef.current = null; + setMiniChatOpen(true); + }, []); + + const openChatWithMember = useCallback((memberUserId: string, memberName: string) => { + const payload = { userId: memberUserId, name: memberName }; + setPendingMember(payload); + pendingMemberRef.current = payload; + setPendingRoomId(null); + setMiniChatOpen(true); + }, []); + + const clearPending = useCallback(() => { + setPendingRoomId(null); + setPendingMember(null); + pendingMemberRef.current = null; + }, []); + + const value = useMemo( + () => ({ + openMiniChat, + openChatRoom, + openChatWithMember, + closeMiniChat, + miniChatOpen, + pendingRoomId, + pendingMember, + clearPending, + selectedGroupId, + setSelectedGroupId + }), + [ + clearPending, + closeMiniChat, + miniChatOpen, + openChatRoom, + openChatWithMember, + openMiniChat, + pendingMember, + pendingRoomId, + selectedGroupId, + setSelectedGroupId + ] + ); + + return {children}; +} + +export function useFamilyOverlay() { + const context = useContext(FamilyOverlayContext); + if (!context) { + throw new Error('useFamilyOverlay must be used within FamilyOverlayProvider'); + } + return context; +} diff --git a/apps/frontend/components/family/family-sidebar-panel.tsx b/apps/frontend/components/family/family-sidebar-panel.tsx new file mode 100644 index 0000000..3a8bcbe --- /dev/null +++ b/apps/frontend/components/family/family-sidebar-panel.tsx @@ -0,0 +1,130 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { Loader2, Search, UsersRound } from 'lucide-react'; +import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; +import { FamilyGroupSelector } from '@/components/family/family-group-selector'; +import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; +import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; +import { useAuth } from '@/components/id/auth-provider'; +import { useSelectedFamily } from '@/hooks/use-primary-family'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export function FamilySidebarPanel() { + const { user, token } = useAuth(); + const { + group, + groupSummaries, + loading, + hasFamily, + selectedGroupId, + setSelectedGroupId, + presenceByUserId, + refresh + } = useSelectedFamily(Boolean(user && token)); + const { openChatWithMember } = useFamilyOverlay(); + const [inviteOpen, setInviteOpen] = useState(false); + + if (!user || !token) return null; + + return ( + <> +
+
+
+

Семья

+ {hasFamily ? ( + + ) : ( + + Создать семью + + )} +
+ {hasFamily ? ( + + ) : null} +
+ + {loading ? ( +
+ + Загрузка... +
+ ) : hasFamily ? ( +
+ {(group?.members ?? []).map((member) => { + const presence = presenceByUserId.get(member.userId); + const isSelf = member.userId === user.id; + return ( + + ); + })} +
+ ) : ( +
+ + Приглашайте близких и общайтесь в чатах +
+ )} +
+ + {hasFamily ? ( + void refresh()} + /> + ) : null} + + ); +} diff --git a/apps/frontend/components/family/mini-family-chat.tsx b/apps/frontend/components/family/mini-family-chat.tsx new file mode 100644 index 0000000..3403c60 --- /dev/null +++ b/apps/frontend/components/family/mini-family-chat.tsx @@ -0,0 +1,394 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, Loader2, MessageCircle, Send, X } from 'lucide-react'; +import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; +import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; +import { useAuth } from '@/components/id/auth-provider'; +import { useToast } from '@/components/id/toast-provider'; +import { useRealtime } from '@/components/notifications/realtime-provider'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useSelectedFamily } from '@/hooks/use-primary-family'; +import { FamilyGroupSelector } from '@/components/family/family-group-selector'; +import { + ChatMessage, + ChatRoom, + fetchChatMessages, + fetchChatRooms, + getApiErrorMessage, + markChatRoomRead, + sendChatMessage +} from '@/lib/api'; +import { findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat'; +import { cn } from '@/lib/utils'; + +function formatMessageTime(value: string) { + return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); +} + +export function MiniFamilyChat() { + const { user, token, isPinLocked } = useAuth(); + const { showToast } = useToast(); + const { subscribe } = useRealtime(); + const { + group, + groupSummaries, + hasFamily, + selectedGroupId, + setSelectedGroupId, + presenceByUserId + } = useSelectedFamily(Boolean(user && token && !isPinLocked)); + const { + miniChatOpen, + openMiniChat, + closeMiniChat, + pendingRoomId, + pendingMember, + clearPending + } = useFamilyOverlay(); + + const [view, setView] = useState<'picker' | 'chat'>('picker'); + const [rooms, setRooms] = useState([]); + const [activeRoom, setActiveRoom] = useState(null); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(''); + const [loadingRooms, setLoadingRooms] = useState(false); + const [loadingMessages, setLoadingMessages] = useState(false); + const [sending, setSending] = useState(false); + const [openingMemberId, setOpeningMemberId] = useState(null); + const messagesEndRef = useRef(null); + const previousGroupIdRef = useRef(null); + + const loadRooms = useCallback(async () => { + if (!group || !token) return; + setLoadingRooms(true); + try { + const response = await fetchChatRooms(group.id, token); + setRooms(response.rooms ?? []); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось загрузить чаты') ?? 'Ошибка'); + } finally { + setLoadingRooms(false); + } + }, [group, showToast, token]); + + const openRoom = useCallback( + async (room: ChatRoom) => { + setActiveRoom(room); + setView('chat'); + setLoadingMessages(true); + try { + const response = await fetchChatMessages(room.id, token); + setMessages(response.messages ?? []); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось загрузить сообщения') ?? 'Ошибка'); + } finally { + setLoadingMessages(false); + } + }, + [showToast, token] + ); + + const openMemberChat = useCallback( + async (memberUserId: string, memberName: string) => { + if (!group || !token || !user) return; + setOpeningMemberId(memberUserId); + try { + const room = await findOrCreateMemberChat(group.id, memberUserId, memberName, user.id, token); + await loadRooms(); + await openRoom(room); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось открыть чат') ?? 'Ошибка'); + } finally { + setOpeningMemberId(null); + } + }, + [group, loadRooms, openRoom, showToast, token, user] + ); + + useEffect(() => { + if (!miniChatOpen || !hasFamily) return; + void loadRooms(); + }, [group?.id, hasFamily, loadRooms, miniChatOpen]); + + useEffect(() => { + if (!group?.id) return; + if (previousGroupIdRef.current && previousGroupIdRef.current !== group.id && miniChatOpen) { + setView('picker'); + setActiveRoom(null); + setMessages([]); + setDraft(''); + } + previousGroupIdRef.current = group.id; + }, [group?.id, miniChatOpen]); + + useEffect(() => { + if (!miniChatOpen) { + setView('picker'); + setActiveRoom(null); + setMessages([]); + setDraft(''); + clearPending(); + return; + } + + if (pendingRoomId && rooms.length) { + const room = rooms.find((item) => item.id === pendingRoomId); + if (room) { + void openRoom(room); + clearPending(); + } + } else if (pendingMember && user) { + void openMemberChat(pendingMember.userId, pendingMember.name); + clearPending(); + } + }, [clearPending, miniChatOpen, openMemberChat, openRoom, pendingMember, pendingRoomId, rooms, user]); + + useEffect(() => { + if (!activeRoom) return; + return subscribe((event) => { + const payload = event.payload; + if (payload?.roomId && payload.roomId !== activeRoom.id) return; + if (event.type === 'chat_message' && payload?.message) { + const message = payload.message as ChatMessage; + setMessages((current) => { + if (current.some((item) => item.id === message.id)) return current; + return [...current, message]; + }); + } + if (event.type === 'chat_message_updated' || event.type === 'chat_message_deleted') { + const updated = payload?.message; + if (updated) { + setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); + } + } + }); + }, [activeRoom, subscribe]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + useEffect(() => { + if (!activeRoom || !token || !messages.length) return; + const last = messages[messages.length - 1]; + const timer = window.setTimeout(() => { + void markChatRoomRead(activeRoom.id, last.id, token).catch(() => {}); + }, 400); + return () => window.clearTimeout(timer); + }, [activeRoom, messages, token]); + + async function handleSend() { + if (!token || !activeRoom || !draft.trim()) return; + setSending(true); + try { + const message = await sendChatMessage(activeRoom.id, { type: 'TEXT', content: draft.trim() }, token); + setMessages((current) => [...current, message]); + setDraft(''); + void loadRooms(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка'); + } finally { + setSending(false); + } + } + + const otherMembers = useMemo( + () => (group?.members ?? []).filter((member) => member.userId !== user?.id), + [group?.members, user?.id] + ); + + if (!user || !token || isPinLocked) return null; + + return ( + <> + + + {miniChatOpen ? ( +
+
+ {view === 'chat' ? ( + + ) : null} +
+

+ {view === 'chat' && activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : 'Чат семьи'} +

+ {hasFamily ? ( + + ) : ( +

Семья не создана

+ )} +
+ +
+ + {!hasFamily ? ( +
+

Создайте семейную группу, чтобы общаться с близкими из любого раздела.

+ + Перейти к созданию семьи + +
+ ) : view === 'picker' ? ( +
+
+

Чаты

+ {loadingRooms ? ( +
+ + Загрузка... +
+ ) : rooms.length ? ( +
+ {rooms.map((room) => ( + + ))} +
+ ) : ( +

Чаты пока не созданы

+ )} +
+ +
+

Написать участнику

+
+ {otherMembers.map((member) => { + const presence = presenceByUserId.get(member.userId); + return ( + + ); + })} + {!otherMembers.length ? ( +

Пригласите участников в семью

+ ) : null} +
+
+ + + Открыть семью полностью + +
+ ) : ( + <> +
+ {loadingMessages ? ( +
+ + Загрузка... +
+ ) : messages.length ? ( + messages.map((message) => { + const mine = message.senderId === user.id; + return ( +
+
+ {!mine ?

{message.senderName}

: null} +

{message.isDeleted ? 'Сообщение удалено' : message.content ?? '…'}

+

{formatMessageTime(message.createdAt)}

+
+
+ ); + }) + ) : ( +

Сообщений пока нет

+ )} +
+
+
+ setDraft(event.target.value)} + placeholder="Сообщение" + className="rounded-xl" + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSend(); + } + }} + /> + +
+ + )} +
+ ) : null} + + ); +} diff --git a/apps/frontend/components/id/avatar-upload.tsx b/apps/frontend/components/id/avatar-upload.tsx index fb76e7c..2a0ddf9 100644 --- a/apps/frontend/components/id/avatar-upload.tsx +++ b/apps/frontend/components/id/avatar-upload.tsx @@ -3,10 +3,11 @@ import { useRef, useState } from 'react'; import { Camera, Loader2 } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Button } from '@/components/ui/button'; import { useToast } from '@/components/id/toast-provider'; import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api'; +import { VerificationBadge } from '@/components/id/verification-badge'; +import { cn } from '@/lib/utils'; interface PresignedUploadResponse { uploadUrl: string; @@ -20,13 +21,21 @@ export function AvatarUpload({ displayName, hasAvatar, token, - onUpdated + onUpdated, + isVerified, + verificationIcon, + className = 'h-24 w-24', + badgeSize = 'md' }: { userId: string; displayName?: string; hasAvatar?: boolean; token: string | null; onUpdated: () => Promise; + isVerified?: boolean; + verificationIcon?: string | null; + className?: string; + badgeSize?: 'xs' | 'sm' | 'md' | 'lg'; }) { const inputRef = useRef(null); const { showToast } = useToast(); @@ -73,24 +82,45 @@ export function AvatarUpload({ } } + function openFilePicker() { + if (isUploading || !token) return; + inputRef.current?.click(); + } + return ( -
- - {avatarUrl ? : null} - {initials} - - - + + {avatarUrl ? : null} + {initials} + + + {isUploading ? ( + + ) : ( + + )} + + + {isVerified ? ( + + ) : null} +
); } @@ -100,21 +130,36 @@ export function AvatarDisplay({ displayName, hasAvatar, token, - className + className, + isVerified, + verificationIcon, + badgeSize = 'sm' }: { userId: string; displayName?: string; hasAvatar?: boolean; token: string | null; className?: string; + isVerified?: boolean; + verificationIcon?: string | null; + badgeSize?: 'xs' | 'sm' | 'md' | 'lg'; }) { const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token); const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID'; return ( - - {avatarUrl ? : null} - {initials} - +
+ + {avatarUrl ? : null} + {initials} + + {isVerified ? ( + + ) : null} +
); } diff --git a/apps/frontend/components/id/shell.tsx b/apps/frontend/components/id/shell.tsx index 037e681..5b408e3 100644 --- a/apps/frontend/components/id/shell.tsx +++ b/apps/frontend/components/id/shell.tsx @@ -3,18 +3,23 @@ import { Sidebar } from './sidebar'; import { UserMenu } from './user-menu'; import { NotificationBell } from '@/components/notifications/notification-bell'; +import { FamilyOverlayProvider } from '@/components/family/family-overlay-provider'; +import { MiniFamilyChat } from '@/components/family/mini-family-chat'; export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) { return ( -
- -
- - + +
+ +
+ + +
+
+
{children}
+
+
-
-
{children}
-
-
+ ); } diff --git a/apps/frontend/components/id/sidebar.tsx b/apps/frontend/components/id/sidebar.tsx index 05949ac..85db282 100644 --- a/apps/frontend/components/id/sidebar.tsx +++ b/apps/frontend/components/id/sidebar.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { FileText, Heart, Home, LockKeyhole, LogOut, ShieldCheck, UserRound } from 'lucide-react'; +import { FamilySidebarPanel } from '@/components/family/family-sidebar-panel'; import { BrandLogo } from '@/components/id/brand-logo'; import { cn } from '@/lib/utils'; import { getAdminLandingPath } from '@/lib/admin-access'; @@ -22,8 +23,8 @@ export function Sidebar({ active }: { active: string }) { : baseItems; return ( -