more fix and update

This commit is contained in:
lendry
2026-06-24 20:15:19 +03:00
parent dcab6557d3
commit 9727cf3f35
53 changed files with 3479 additions and 494 deletions

View File

@@ -17,7 +17,7 @@ import {
withDocumentPhotos,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
interface PresignedUploadResponse {
@@ -266,12 +266,7 @@ export function DocumentFormDialog({
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) throw new Error('Не удалось загрузить фото');
await uploadMediaObject(presigned, file, file.type);
const nextPhotos = [...currentPhotos, presigned.storageKey];
photoStorageKeysRef.current = nextPhotos;

View File

@@ -31,14 +31,17 @@ import {
ChatRoom,
createChatRoom,
FamilyGroup,
FamilyInviteCandidate,
fetchChatMessages,
fetchChatRooms,
fetchFamilyGroup,
getApiErrorMessage,
searchFamilyInviteUsers,
sendChatMessage,
sendFamilyInvite,
setChatRoomMuted,
updateFamilyGroup,
uploadMediaObject,
voteChatPoll
} from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -63,6 +66,8 @@ const EMOJIS = ['😀', '😂', '❤️', '👍', '🎉', '🔥', '😍', '🙏'
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
interface LoadedChatMedia {
@@ -108,7 +113,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [inviteOpen, setInviteOpen] = useState(false);
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const [pollOpen, setPollOpen] = useState(false);
const [inviteTarget, setInviteTarget] = useState('');
const [inviteQuery, setInviteQuery] = useState('');
const [inviteResults, setInviteResults] = useState<FamilyInviteCandidate[]>([]);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [inviteSearching, setInviteSearching] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
@@ -300,7 +308,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
token
);
await fetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
await uploadMediaObject(presigned, file, file.type);
await apiFetch(`/media/families/${groupId}/avatar/confirm`, {
method: 'POST',
body: JSON.stringify({ storageKey: presigned.storageKey })
@@ -313,17 +321,38 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}
async function submitInvite() {
if (!token || !inviteTarget.trim()) return;
if (!token || !selectedInviteUser) return;
try {
await sendFamilyInvite(groupId, inviteTarget.trim(), token);
await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token);
setInviteOpen(false);
setInviteTarget('');
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 {
@@ -377,11 +406,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
},
token
);
await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
await uploadMediaObject(presigned, file, contentType);
const message = await sendChatMessage(
activeRoomId,
{
@@ -747,11 +772,72 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
event.target.value = '';
}} />
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<Dialog open={inviteOpen} onOpenChange={(open) => {
setInviteOpen(open);
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
}
}}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader><DialogTitle>Пригласить в семью</DialogTitle></DialogHeader>
<Input placeholder="Email, телефон или логин" value={inviteTarget} onChange={(event) => setInviteTarget(event.target.value)} />
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitInvite()}>Отправить приглашение</Button>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">{selectedInviteUser.displayName}</p>
<p className="text-sm text-[#667085]">
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого пользователя
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон или логин"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем пользователей...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={candidate.id}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
setSelectedInviteUser(candidate);
setInviteResults([]);
}}
>
<Avatar className="h-9 w-9">
<AvatarFallback>{candidate.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{candidate.displayName}</p>
<p className="truncate text-sm text-[#667085]">
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser} onClick={() => void submitInvite()}>
Отправить приглашение
</Button>
</DialogContent>
</Dialog>

View File

@@ -39,7 +39,10 @@ interface AuthContextValue {
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
beginTotpLogin: (recipient: string) => Promise<string>;
verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>;
applyLoginAuth: (auth: AuthTokens) => AuthTokens;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => void;
@@ -381,6 +384,50 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[saveSession]
);
const beginTotpLogin = React.useCallback(async (recipient: string) => {
const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', {
method: 'POST',
body: JSON.stringify({
recipient,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
return response.totpChallengeToken;
}, []);
const verifyTotpLogin = React.useCallback(
async (totpChallengeToken: string, code: string) => {
const auth = await apiFetch<AuthTokens>('/auth/totp/verify', {
method: 'POST',
body: JSON.stringify({ totpChallengeToken, code })
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const applyLoginAuth = React.useCallback(
(auth: AuthTokens) => {
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
activatePinLock(auth.sessionId);
}
return auth;
},
[activatePinLock, saveSession]
);
const completePin = React.useCallback(
async (sessionId: string, pin: string) => {
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
@@ -452,7 +499,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
beginTotpLogin,
verifyTotpLogin,
completePin,
applyLoginAuth,
register,
refreshProfile,
logout

View File

@@ -6,12 +6,13 @@ 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 } from '@/lib/api';
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt: string;
uploadToken?: string;
}
export function AvatarUpload({
@@ -53,15 +54,7 @@ export function AvatarUpload({
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) {
throw new Error('Не удалось загрузить файл в хранилище');
}
await uploadMediaObject(presigned, file, file.type);
await apiFetch('/media/avatars/confirm', {
method: 'POST',

View File

@@ -28,6 +28,8 @@ const buttonVariants = cva(
}
);
export { buttonVariants };
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -0,0 +1,79 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker, getDefaultClassNames, type DayButtonProps } from 'react-day-picker';
import { ru } from 'react-day-picker/locale';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
function CalendarDayButton({ className, day, modifiers, ...props }: DayButtonProps) {
return (
<button
type="button"
className={cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
modifiers.selected && 'bg-[#111827] text-white hover:bg-[#111827] hover:text-white',
modifiers.today && !modifiers.selected && 'bg-[#f4f5f8] text-[#111827]',
modifiers.outside && 'text-[#a8adbc] opacity-50',
modifiers.disabled && 'text-[#a8adbc] opacity-50',
className
)}
{...props}
/>
);
}
export function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) {
const defaults = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
locale={ru}
className={cn('p-3', className)}
classNames={{
root: cn('rdp-root', defaults.root),
months: cn('flex flex-col gap-4 sm:flex-row', defaults.months),
month: cn('flex flex-col gap-4', defaults.month),
month_caption: cn('flex justify-center pt-1 relative items-center', defaults.month_caption),
caption_label: cn('text-sm font-medium capitalize', defaults.caption_label),
dropdowns: cn('flex items-center justify-center gap-2', defaults.dropdowns),
dropdown_root: cn('relative inline-flex items-center gap-1', defaults.dropdown_root),
dropdown: defaults.dropdown,
months_dropdown: defaults.months_dropdown,
years_dropdown: defaults.years_dropdown,
nav: cn('flex items-center gap-1', defaults.nav),
button_previous: cn(
buttonVariants({ variant: 'ghost' }),
'absolute left-1 h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100',
defaults.button_previous
),
button_next: cn(
buttonVariants({ variant: 'ghost' }),
'absolute right-1 h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100',
defaults.button_next
),
month_grid: cn('w-full border-collapse space-y-1', defaults.month_grid),
weekdays: cn('flex', defaults.weekdays),
weekday: cn('text-[#667085] rounded-md w-9 font-normal text-[0.8rem]', defaults.weekday),
week: cn('flex w-full mt-2', defaults.week),
day: cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20', defaults.day),
day_button: cn('h-9 w-9 p-0 font-normal aria-selected:opacity-100', defaults.day_button),
selected: cn('bg-[#111827] text-white hover:bg-[#111827] hover:text-white focus:bg-[#111827] focus:text-white', defaults.selected),
today: cn('bg-[#f4f5f8] text-[#111827]', defaults.today),
outside: cn('text-[#a8adbc] opacity-50', defaults.outside),
disabled: cn('text-[#a8adbc] opacity-50', defaults.disabled),
hidden: cn('invisible', defaults.hidden),
...classNames
}}
components={{
Chevron: ({ orientation }) => (orientation === 'left' ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />),
DayButton: CalendarDayButton
}}
{...props}
/>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { format, parseISO } from 'date-fns';
import { ru } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export function DatePicker({
value,
onChange,
placeholder = 'Дата рождения',
className,
disabled
}: {
value?: string;
onChange: (value: string | undefined) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}) {
const selected = value ? parseISO(value) : undefined;
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary"
disabled={disabled}
className={cn(
'h-11 w-full justify-start rounded-2xl border border-[#eceef4] bg-white px-4 text-left font-normal text-[#111827] hover:bg-white',
!value && 'text-[#667085]',
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4 text-[#667085]" />
{value ? format(parseISO(value), 'd MMMM yyyy', { locale: ru }) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={(date) => onChange(date ? format(date, 'yyyy-MM-dd') : undefined)}
disabled={(date) => date > new Date()}
defaultMonth={selected ?? new Date(1990, 0, 1)}
captionLayout="dropdown"
hideNavigation
startMonth={new Date(1920, 0)}
endMonth={new Date()}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -3,16 +3,35 @@
import { useRef } from 'react';
import { cn } from '@/lib/utils';
const otpThemes = {
dark: {
box: 'border-[#555762] bg-transparent text-white focus:border-[#7b7f8f] focus:ring-white/10'
},
light: {
box: 'border-[#eceef4] bg-white text-[#111827] focus:border-[#c7cad6] focus:ring-[#eceef4]'
}
} as const;
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
length?: number;
disabled?: boolean;
variant?: keyof typeof otpThemes;
className?: string;
}
export function OtpInput({ value, onChange, onComplete, length = 6, disabled = false, className }: OtpInputProps) {
export function OtpInput({
value,
onChange,
onComplete,
length = 6,
disabled = false,
variant = 'dark',
className
}: OtpInputProps) {
const theme = otpThemes[variant];
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length }, (_, index) => value[index] ?? '');
@@ -83,7 +102,10 @@ export function OtpInput({ value, onChange, onComplete, length = 6, disabled = f
onChange={(event) => handleChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
className="h-14 w-full rounded-2xl border border-[#555762] bg-transparent text-center text-2xl font-semibold text-white outline-none transition focus:border-[#7b7f8f] focus:ring-4 focus:ring-white/10 disabled:opacity-50"
className={cn(
'h-14 w-full rounded-2xl border text-center text-2xl font-semibold outline-none transition focus:ring-4 disabled:opacity-50',
theme.box
)}
/>
))}
</div>

View File

@@ -65,34 +65,71 @@ export function toE164(country: CountryOption, digits: string) {
return `${country.code}${digits.replace(/\D/g, '').slice(0, country.maxDigits)}`;
}
export function parseE164Phone(e164: string): { country: CountryOption; digits: string } {
if (!e164) {
return { country: phoneCountries[0], digits: '' };
}
const sorted = [...phoneCountries].sort((a, b) => b.code.length - a.code.length);
for (const country of sorted) {
if (e164.startsWith(country.code)) {
return { country, digits: e164.slice(country.code.length).replace(/\D/g, '') };
}
}
return { country: phoneCountries[0], digits: e164.replace(/\D/g, '') };
}
const phoneThemes = {
dark: {
shell: 'border-[#555762] bg-transparent text-white focus-within:border-[#7b7f8f] focus-within:ring-white/10',
trigger: 'text-white hover:bg-[#2a2c36]',
chevron: 'text-[#8f92a0]',
input: 'text-white placeholder:text-[#8f92a0]',
clear: 'text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white'
},
light: {
shell: 'border-[#eceef4] bg-white text-[#111827] focus-within:border-[#c7cad6] focus-within:ring-[#eceef4]',
trigger: 'text-[#111827] hover:bg-[#f4f5f8]',
chevron: 'text-[#667085]',
input: 'text-[#111827] placeholder:text-[#667085]',
clear: 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#111827]'
}
} as const;
export function PhoneInput({
country,
value,
onCountryChange,
onValueChange,
className
className,
variant = 'dark',
required = true
}: {
country: CountryOption;
value: string;
onCountryChange: (country: CountryOption) => void;
onValueChange: (digits: string) => void;
className?: string;
variant?: keyof typeof phoneThemes;
required?: boolean;
}) {
const maskedValue = formatPhoneNumber(value, country);
const theme = phoneThemes[variant];
return (
<div
className={cn(
'flex h-[58px] w-full items-center rounded-2xl border border-[#555762] bg-transparent text-white transition focus-within:border-[#7b7f8f] focus-within:ring-4 focus-within:ring-white/10',
'flex h-[58px] w-full items-center rounded-2xl border transition focus-within:ring-4',
variant === 'light' ? 'h-11' : '',
theme.shell,
className
)}
>
<Popover>
<PopoverTrigger asChild>
<button type="button" className="flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 text-white outline-none hover:bg-[#2a2c36]" aria-label="Выбрать страну">
<button type="button" className={cn('flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 outline-none', theme.trigger)} aria-label="Выбрать страну">
<FlagIcon src={country.flagSrc} />
<span className="text-base font-semibold">{country.code}</span>
<ChevronDown className="h-4 w-4 text-[#8f92a0]" />
<ChevronDown className={cn('h-4 w-4', theme.chevron)} />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-1">
@@ -122,16 +159,16 @@ export function PhoneInput({
</Popover>
<input
className="h-full min-w-0 flex-1 bg-transparent px-1 text-lg text-white outline-none placeholder:text-[#8f92a0]"
className={cn('h-full min-w-0 flex-1 bg-transparent px-1 text-lg outline-none', variant === 'light' ? 'text-base' : '', theme.input)}
inputMode="tel"
placeholder={country.code === '+375' ? '(29) 123-45-67' : '(999) 123-45-67'}
value={maskedValue}
onChange={(event) => onValueChange(event.target.value.replace(/\D/g, '').slice(0, country.maxDigits))}
required
required={required}
/>
{value ? (
<button type="button" className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white" onClick={() => onValueChange('')} aria-label="Очистить телефон">
<button type="button" className={cn('mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full', theme.clear)} onClick={() => onValueChange('')} aria-label="Очистить телефон">
<X className="h-4 w-4" />
</button>
) : null}