first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { Loader2, MapPin, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/id/toast-provider';
import { ADDRESS_LABELS, type AddressLabel } from '@/lib/address-catalog';
import { forwardGeocode, reverseGeocode } from '@/lib/geocoding';
import { deleteUserAddress, getApiErrorMessage, upsertUserAddress, UserAddress } from '@/lib/api';
import { cn } from '@/lib/utils';
const AddressMapPicker = dynamic(
() => import('@/components/addresses/address-map-picker').then((module) => module.AddressMapPicker),
{
ssr: false,
loading: () => (
<div className="flex h-[280px] items-center justify-center rounded-2xl border border-[#eceef4] bg-[#fafbfd] text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка карты...
</div>
)
}
);
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-sm font-medium text-[#1f2430]">{children}</label>;
}
export function AddressFormDialog({
open,
onOpenChange,
label,
userId,
token,
existing,
onSaved
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
label: AddressLabel;
userId: string;
token?: string | null;
existing: UserAddress | null;
onSaved: () => void;
}) {
const { showToast } = useToast();
const [mode, setMode] = useState<'map' | 'manual'>('map');
const [city, setCity] = useState('');
const [street, setStreet] = useState('');
const [house, setHouse] = useState('');
const [apartment, setApartment] = useState('');
const [comment, setComment] = useState('');
const [fullAddress, setFullAddress] = useState('');
const [latitude, setLatitude] = useState<number | undefined>();
const [longitude, setLongitude] = useState<number | undefined>();
const [isGeocoding, setIsGeocoding] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open) return;
setMode(existing?.latitude != null && existing?.longitude != null ? 'map' : existing ? 'manual' : 'map');
setCity(existing?.city ?? '');
setStreet(existing?.street ?? '');
setHouse(existing?.house ?? '');
setApartment(existing?.apartment ?? '');
setComment(existing?.comment ?? '');
setFullAddress(existing?.fullAddress ?? '');
setLatitude(existing?.latitude);
setLongitude(existing?.longitude);
}, [existing, open]);
useEffect(() => {
return () => {
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
};
}, []);
async function handleMapPick(lat: number, lng: number) {
setLatitude(lat);
setLongitude(lng);
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
geocodeTimer.current = setTimeout(async () => {
setIsGeocoding(true);
try {
const parsed = await reverseGeocode(lat, lng);
if (parsed) {
setCity(parsed.city);
setStreet(parsed.street);
setHouse(parsed.house);
setFullAddress(parsed.fullAddress);
}
} finally {
setIsGeocoding(false);
}
}, 450);
}
async function locateManualAddress() {
const query = fullAddress.trim() || [`г. ${city}`, `${street}, ${house}`].filter(Boolean).join(', ');
if (!query.trim()) {
showToast('Сначала укажите адрес для поиска');
return;
}
setIsGeocoding(true);
try {
const result = await forwardGeocode(query);
if (!result) {
showToast('Адрес не найден на карте');
return;
}
setLatitude(result.lat);
setLongitude(result.lng);
setCity(result.parsed.city || city);
setStreet(result.parsed.street || street);
setHouse(result.parsed.house || house);
setFullAddress(result.parsed.fullAddress);
setMode('map');
} finally {
setIsGeocoding(false);
}
}
async function saveAddress() {
setIsSaving(true);
try {
await upsertUserAddress(
userId,
{
addressId: existing?.id,
label,
city: city.trim(),
street: street.trim(),
house: house.trim(),
apartment: apartment.trim() || undefined,
comment: comment.trim() || undefined,
latitude,
longitude,
fullAddress: fullAddress.trim() || undefined
},
token
);
showToast(existing ? 'Адрес обновлён' : 'Адрес добавлен');
onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось сохранить адрес');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
async function removeAddress() {
if (!existing) return;
setIsDeleting(true);
try {
await deleteUserAddress(userId, existing.id, token);
showToast('Адрес удалён');
onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось удалить адрес');
if (message) showToast(message);
} finally {
setIsDeleting(false);
}
}
const title = existing
? `Редактировать: ${ADDRESS_LABELS[label].title}`
: `Добавить: ${ADDRESS_LABELS[label].title}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-y-auto rounded-[28px] sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Tabs value={mode} onValueChange={(value) => setMode(value as 'map' | 'manual')}>
<TabsList className="grid w-full grid-cols-2 bg-[#f4f5f8] p-1">
<TabsTrigger
value="map"
className={cn(
'rounded-xl px-4 py-2 text-sm data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm',
'text-[#667085]'
)}
>
На карте
</TabsTrigger>
<TabsTrigger
value="manual"
className={cn(
'rounded-xl px-4 py-2 text-sm data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm',
'text-[#667085]'
)}
>
Вручную
</TabsTrigger>
</TabsList>
<TabsContent value="map" className="space-y-4">
<AddressMapPicker latitude={latitude} longitude={longitude} onPick={handleMapPick} />
{isGeocoding ? (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Определяем адрес...
</div>
) : fullAddress ? (
<div className="flex items-start gap-2 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm">
<MapPin className="mt-0.5 h-4 w-4 shrink-0 text-[#667085]" />
<span>{fullAddress}</span>
</div>
) : (
<p className="text-sm text-[#667085]">Выберите точку на карте адрес подставится автоматически</p>
)}
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="space-y-2">
<FieldLabel>Полный адрес</FieldLabel>
<Input
placeholder="Например: г. Москва, ул. Тверская, 1"
value={fullAddress}
onChange={(event) => setFullAddress(event.target.value)}
/>
</div>
<Button type="button" variant="secondary" className="w-full" onClick={() => void locateManualAddress()} disabled={isGeocoding}>
{isGeocoding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ищем на карте...
</>
) : (
'Найти на карте'
)}
</Button>
</TabsContent>
</Tabs>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<FieldLabel>Город</FieldLabel>
<Input placeholder="Москва" value={city} onChange={(event) => setCity(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Улица</FieldLabel>
<Input placeholder="Тверская" value={street} onChange={(event) => setStreet(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Дом</FieldLabel>
<Input placeholder="1" value={house} onChange={(event) => setHouse(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Квартира</FieldLabel>
<Input placeholder="Необязательно" value={apartment} onChange={(event) => setApartment(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Комментарий</FieldLabel>
<Input placeholder="Подъезд, домофон..." value={comment} onChange={(event) => setComment(event.target.value)} />
</div>
</div>
<div className="mt-6 flex gap-3">
{existing ? (
<Button
type="button"
variant="secondary"
className="text-red-600 hover:text-red-700"
onClick={() => void removeAddress()}
disabled={isSaving || isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
) : null}
<Button type="button" variant="secondary" className="flex-1" onClick={() => onOpenChange(false)} disabled={isSaving || isDeleting}>
Отмена
</Button>
<Button type="button" className="flex-1" onClick={() => void saveAddress()} disabled={isSaving || isDeleting || isGeocoding}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Сохраняем...
</>
) : (
'Сохранить'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useEffect, useRef } from 'react';
import L from 'leaflet';
const DEFAULT_CENTER = { lat: 55.7558, lng: 37.6173 };
let iconsFixed = false;
function fixLeafletIcons() {
if (iconsFixed || typeof window === 'undefined') return;
iconsFixed = true;
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}
export function AddressMapPicker({
latitude,
longitude,
onPick
}: {
latitude?: number;
longitude?: number;
onPick: (lat: number, lng: number) => void;
}) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<L.Map | null>(null);
const markerRef = useRef<L.Marker | null>(null);
const onPickRef = useRef(onPick);
useEffect(() => {
onPickRef.current = onPick;
}, [onPick]);
useEffect(() => {
fixLeafletIcons();
if (!mapRef.current || mapInstance.current) return;
const lat = latitude ?? DEFAULT_CENTER.lat;
const lng = longitude ?? DEFAULT_CENTER.lng;
const map = L.map(mapRef.current, { zoomControl: true }).setView([lat, lng], latitude != null ? 16 : 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
const marker = L.marker([lat, lng], { draggable: true }).addTo(map);
const emitPick = (nextLat: number, nextLng: number) => {
onPickRef.current(nextLat, nextLng);
};
marker.on('dragend', () => {
const position = marker.getLatLng();
emitPick(position.lat, position.lng);
});
map.on('click', (event) => {
marker.setLatLng(event.latlng);
emitPick(event.latlng.lat, event.latlng.lng);
});
mapInstance.current = map;
markerRef.current = marker;
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
});
resizeObserver.observe(mapRef.current);
return () => {
resizeObserver.disconnect();
map.remove();
mapInstance.current = null;
markerRef.current = null;
};
}, []);
useEffect(() => {
if (!markerRef.current || !mapInstance.current) return;
if (latitude == null || longitude == null) return;
const latLng = L.latLng(latitude, longitude);
markerRef.current.setLatLng(latLng);
mapInstance.current.setView(latLng, Math.max(mapInstance.current.getZoom(), 15));
}, [latitude, longitude]);
return (
<div className="overflow-hidden rounded-2xl border border-[#eceef4]">
<div ref={mapRef} className="h-[280px] w-full" />
<p className="border-t border-[#eceef4] bg-[#fafbfd] px-4 py-2 text-xs text-[#667085]">
Нажмите на карту или перетащите маркер, чтобы указать адрес
</p>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, Home, MapPin } from 'lucide-react';
import { AddressFormDialog } from '@/components/addresses/address-form-dialog';
import { ActionTile, SectionTitle } from '@/components/id/action-tile';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import {
ADDRESS_LABELS,
formatAddressLine,
getOtherAddresses,
getPrimaryAddress,
type AddressLabel
} from '@/lib/address-catalog';
import { fetchUserAddresses, getApiErrorMessage, UserAddress } from '@/lib/api';
function AddressRow({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-4 border-b border-[#eceef4] px-4 py-4 text-left transition last:border-b-0 hover:bg-[#fafbfd]"
>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
<MapPin className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{title}</div>
<div className="truncate text-sm text-[#667085]">{text}</div>
</div>
<ChevronRight className="h-5 w-5 shrink-0 text-[#a8adbc]" />
</button>
);
}
export function AddressQuickSection({
layout,
isPinLocked = false,
isReady = true
}: {
layout: 'home' | 'data';
isPinLocked?: boolean;
isReady?: boolean;
}) {
const { user, token } = useAuth();
const { showToast } = useToast();
const [addresses, setAddresses] = useState<UserAddress[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [activeLabel, setActiveLabel] = useState<AddressLabel>('HOME');
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
const loadAddresses = useCallback(async () => {
if (!user || !token || isPinLocked) return;
try {
const response = await fetchUserAddresses(user.id, token);
setAddresses(response.addresses ?? []);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
if (message) showToast(message);
}
}, [isPinLocked, showToast, token, user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadAddresses();
}, [isPinLocked, isReady, loadAddresses, user]);
const homeAddress = useMemo(() => getPrimaryAddress(addresses, 'HOME') as UserAddress | null, [addresses]);
const workAddress = useMemo(() => getPrimaryAddress(addresses, 'WORK') as UserAddress | null, [addresses]);
const otherAddresses = useMemo(() => getOtherAddresses(addresses), [addresses]);
function openAddress(label: AddressLabel, existing: UserAddress | null = null) {
setActiveLabel(label);
if (label === 'HOME') setEditingAddress(existing ?? homeAddress);
else if (label === 'WORK') setEditingAddress(existing ?? workAddress);
else setEditingAddress(existing);
setFormOpen(true);
}
const tiles = (
<div className={layout === 'home' ? 'grid grid-cols-3 gap-4' : 'mt-4 grid grid-cols-3 gap-3'}>
<ActionTile
icon={Home}
label={ADDRESS_LABELS.HOME.title}
description={homeAddress ? formatAddressLine(homeAddress) : layout === 'data' ? 'Не указан' : undefined}
className={layout === 'home' ? 'min-h-[76px]' : 'min-h-[76px]'}
onClick={() => openAddress('HOME')}
/>
<ActionTile
icon={MapPin}
label={ADDRESS_LABELS.WORK.title}
description={workAddress ? formatAddressLine(workAddress) : layout === 'data' ? 'Не указан' : undefined}
className="min-h-[76px]"
onClick={() => openAddress('WORK')}
/>
<ActionTile
icon={MapPin}
label={ADDRESS_LABELS.OTHER.shortTitle}
description={
otherAddresses.length > 0
? `${otherAddresses.length} ${otherAddresses.length === 1 ? 'адрес' : otherAddresses.length < 5 ? 'адреса' : 'адресов'}`
: layout === 'data'
? 'Добавить'
: undefined
}
className="min-h-[76px]"
onClick={() => openAddress('OTHER')}
/>
</div>
);
return (
<>
{layout === 'home' ? (
<>
<SectionTitle>Адреса</SectionTitle>
{tiles}
</>
) : (
<section className="mt-10">
<h2 className="text-2xl font-medium">Адреса</h2>
<p className="mt-1 text-sm text-[#667085]">Укажите адреса на карте или введите их вручную.</p>
{tiles}
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
{addresses.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[#667085]">Адреса пока не добавлены</p>
) : (
addresses.map((address) => (
<AddressRow
key={address.id}
title={ADDRESS_LABELS[address.label as AddressLabel]?.title ?? address.label}
text={formatAddressLine(address)}
onClick={() => openAddress(address.label as AddressLabel, address)}
/>
))
)}
</div>
</section>
)}
{user ? (
<AddressFormDialog
open={formOpen}
onOpenChange={setFormOpen}
label={activeLabel}
userId={user.id}
token={token}
existing={editingAddress}
onSaved={loadAddresses}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,502 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Camera, Loader2, X } from 'lucide-react';
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/id/toast-provider';
import {
buildDocumentNumber,
DOCUMENT_TYPES,
DocumentTypeDef,
LICENSE_CATEGORIES,
parseDocumentPhotos,
parseMetadata,
withDocumentPhotos,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt: string;
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-sm font-medium text-[#1f2430]">{children}</label>;
}
function GenderToggle({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<div className="flex rounded-2xl bg-[#f4f5f8] p-1">
{[
{ id: 'male', label: 'Мужской' },
{ id: 'female', label: 'Женский' }
].map((option) => (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
'flex-1 rounded-xl px-3 py-2 text-sm transition',
value === option.id ? 'bg-white font-medium shadow-sm' : 'text-[#667085]'
)}
>
{option.label}
</button>
))}
</div>
);
}
function CategoryPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean));
return (
<div className="grid grid-cols-4 gap-2">
{LICENSE_CATEGORIES.map((category) => {
const active = selected.has(category);
return (
<button
key={category}
type="button"
onClick={() => {
const next = new Set(selected);
if (active) next.delete(category);
else next.add(category);
onChange(Array.from(next).join(','));
}}
className={cn(
'rounded-xl border px-2 py-2 text-sm transition',
active ? 'border-[#20212b] bg-[#20212b] text-white' : 'border-[#eceef4] bg-white text-[#667085]'
)}
>
{category}
</button>
);
})}
</div>
);
}
function buildInitialValues(config: DocumentTypeDef, existing?: UserDocument | null) {
const metadata = parseMetadata(existing?.metadataJson);
const initial: Record<string, string> = {};
for (const field of config.fields) {
const raw = metadata[field.key];
initial[field.key] = typeof raw === 'string' ? raw : '';
if (field.latinKey) {
const latinRaw = metadata[field.latinKey];
initial[field.latinKey] = typeof latinRaw === 'string' ? latinRaw : '';
}
}
if (existing?.issuedAt) initial.issuedAt = existing.issuedAt.slice(0, 10);
if (existing?.expiresAt) initial.expiresAt = existing.expiresAt.slice(0, 10);
return initial;
}
export function DocumentFormDialog({
open,
onOpenChange,
documentType,
userId,
token,
existing,
onSaved,
readOnly
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentTypeCode;
userId: string;
token: string | null;
existing?: UserDocument | null;
onSaved: () => void | Promise<void>;
readOnly?: boolean;
}) {
const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
const { showToast } = useToast();
const photoInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const fieldValuesRef = useRef<Record<string, string>>({});
const draftDocumentIdRef = useRef<string | undefined>(undefined);
const photoStorageKeysRef = useRef<string[]>([]);
const wasOpenRef = useRef(false);
const [formInstanceKey, setFormInstanceKey] = useState(0);
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
const [isSaving, setIsSaving] = useState(false);
const [isPhotoUploading, setIsPhotoUploading] = useState(false);
useEffect(() => {
if (!open) {
wasOpenRef.current = false;
fieldValuesRef.current = {};
draftDocumentIdRef.current = undefined;
photoStorageKeysRef.current = [];
return;
}
if (!config || wasOpenRef.current) return;
wasOpenRef.current = true;
const initial = buildInitialValues(config, existing);
const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson));
fieldValuesRef.current = { ...initial };
photoStorageKeysRef.current = photos;
draftDocumentIdRef.current = existing?.id;
setPickerValues(initial);
setPhotoStorageKeys(photos);
setDraftDocumentId(existing?.id);
setFormInstanceKey((current) => current + 1);
}, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]);
useEffect(() => {
if (!open || !existing?.id || draftDocumentIdRef.current) return;
draftDocumentIdRef.current = existing.id;
setDraftDocumentId(existing.id);
}, [open, existing?.id]);
function syncField(key: string, value: string) {
fieldValuesRef.current[key] = value;
}
function setPickerField(key: string, value: string) {
syncField(key, value);
setPickerValues((current) => ({ ...current, [key]: value }));
}
function collectFormValues() {
const values = { ...fieldValuesRef.current, ...pickerValues };
if (formRef.current) {
for (const element of formRef.current.elements) {
if (element instanceof HTMLInputElement && element.name) {
if (element.type === 'checkbox') {
values[element.name] = element.checked ? 'true' : '';
} else if (element.type !== 'radio') {
values[element.name] = element.value;
}
} else if (element instanceof HTMLTextAreaElement && element.name) {
values[element.name] = element.value;
}
}
}
fieldValuesRef.current = values;
return values;
}
async function commitFocusedField() {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
activeElement.blur();
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function buildMetadataPayload(source: Record<string, string>, photos: string[]) {
const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value;
}
return JSON.stringify(withDocumentPhotos(metadata, photos));
}
function buildSaveBody(currentValues: Record<string, string>, photos: string[]) {
return {
number: buildDocumentNumber(config!, currentValues),
issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined,
expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).toISOString() : undefined,
metadataJson: buildMetadataPayload(currentValues, photos)
};
}
async function patchDocument(documentId: string, currentValues: Record<string, string>, photos: string[]) {
if (!token || !config) return;
await apiFetch(
`/documents/users/${userId}/${documentId}`,
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) },
token
);
}
async function ensureDocumentId(currentValues: Record<string, string>, photos: string[]) {
if (draftDocumentIdRef.current) {
return { id: draftDocumentIdRef.current, created: false };
}
if (!token || !config) {
return { id: undefined, created: false };
}
const created = await apiFetch<UserDocument>(
`/documents/users/${userId}`,
{
method: 'POST',
body: JSON.stringify({
type: documentType,
...buildSaveBody(currentValues, photos)
})
},
token
);
draftDocumentIdRef.current = created.id;
setDraftDocumentId(created.id);
return { id: created.id, created: true };
}
async function uploadPhoto(file: File) {
if (!token || readOnly) return;
setIsPhotoUploading(true);
try {
await commitFocusedField();
const currentValues = collectFormValues();
const currentPhotos = photoStorageKeysRef.current;
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos);
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки фото');
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/documents/${targetDocumentId}/photo/upload-url`,
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) throw new Error('Не удалось загрузить фото');
const nextPhotos = [...currentPhotos, presigned.storageKey];
photoStorageKeysRef.current = nextPhotos;
setPhotoStorageKeys(nextPhotos);
await patchDocument(targetDocumentId, currentValues, nextPhotos);
if (created) {
await onSaved();
}
showToast('Фото добавлено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить фото');
if (message) showToast(message);
} finally {
setIsPhotoUploading(false);
if (photoInputRef.current) photoInputRef.current.value = '';
}
}
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? []);
for (const file of files) {
await uploadPhoto(file);
}
}
function removePhoto(storageKey: string) {
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
photoStorageKeysRef.current = nextPhotos;
setPhotoStorageKeys(nextPhotos);
}
async function handleSave() {
if (!token || !config || readOnly) return;
setIsSaving(true);
try {
await commitFocusedField();
const currentValues = collectFormValues();
const photos = photoStorageKeysRef.current;
const documentId = draftDocumentIdRef.current ?? existing?.id;
if (documentId) {
await patchDocument(documentId, currentValues, photos);
showToast('Документ обновлён');
} else {
const created = await apiFetch<UserDocument>(
`/documents/users/${userId}`,
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos) }) },
token
);
draftDocumentIdRef.current = created.id;
setDraftDocumentId(created.id);
showToast('Документ сохранён');
}
await onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось сохранить документ');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
if (!config) return null;
const initialValues = fieldValuesRef.current;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[420px]">
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
<div className="flex items-center justify-between">
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
<X className="h-4 w-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-4 px-5 pb-5">
{photoStorageKeys.length > 0 ? (
<DocumentPhotoGallery
userId={userId}
token={token}
storageKeys={photoStorageKeys}
readOnly={readOnly}
onRemove={readOnly ? undefined : removePhoto}
/>
) : null}
{!readOnly ? (
<>
<button
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={isPhotoUploading}
className="flex w-full items-center justify-center gap-2 rounded-[20px] bg-[#f4f5f8] px-4 py-4 text-sm font-medium transition hover:bg-[#eceef4]"
>
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
{photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'}
</button>
<input ref={photoInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoChange} />
</>
) : null}
<form
key={formInstanceKey}
ref={formRef}
className="space-y-3 rounded-[24px] bg-[#f4f5f8] p-4"
onSubmit={(event) => {
event.preventDefault();
void handleSave();
}}
>
{config.fields.map((field) => {
if (readOnly) {
const value = initialValues[field.key];
if (!value) return null;
return (
<div key={field.key}>
<FieldLabel>{field.label}</FieldLabel>
<p className="mt-1 text-sm text-[#1f2430]">{value}</p>
</div>
);
}
if (field.kind === 'checkbox') {
return (
<label key={field.key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name={field.key}
defaultChecked={initialValues[field.key] === 'true'}
onChange={(event) => syncField(field.key, event.target.checked ? 'true' : '')}
/>
{field.label}
</label>
);
}
if (field.kind === 'gender') {
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<input type="hidden" name={field.key} value={pickerValues[field.key] ?? ''} readOnly />
<GenderToggle value={pickerValues[field.key] ?? ''} onChange={(value) => setPickerField(field.key, value)} />
</div>
);
}
if (field.kind === 'categories') {
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<input type="hidden" name={field.key} value={pickerValues[field.key] ?? ''} readOnly />
<CategoryPicker value={pickerValues[field.key] ?? ''} onChange={(value) => setPickerField(field.key, value)} />
</div>
);
}
if (field.kind === 'textarea') {
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<textarea
name={field.key}
defaultValue={initialValues[field.key] ?? ''}
onChange={(event) => syncField(field.key, event.target.value)}
onBlur={(event) => syncField(field.key, event.target.value)}
onCompositionEnd={(event) => syncField(field.key, event.currentTarget.value)}
placeholder={field.placeholder ?? field.label}
className="min-h-[96px] w-full rounded-2xl border border-[#eceef4] bg-white px-4 py-3 text-sm outline-none focus:border-[#20212b]"
/>
</div>
);
}
return (
<div key={field.key} className="space-y-2">
<FieldLabel>{field.label}</FieldLabel>
<Input
type={field.kind === 'date' ? 'date' : 'text'}
name={field.key}
defaultValue={initialValues[field.key] ?? ''}
onChange={(event) => syncField(field.key, event.target.value)}
onBlur={(event) => syncField(field.key, event.target.value)}
onCompositionEnd={(event) => syncField(field.key, event.currentTarget.value)}
placeholder={field.placeholder ?? field.label}
readOnly={readOnly}
/>
{field.latinKey ? (
<div className="space-y-2">
<FieldLabel>{field.latinLabel ?? field.latinKey}</FieldLabel>
<Input
name={field.latinKey}
defaultValue={initialValues[field.latinKey] ?? ''}
onChange={(event) => syncField(field.latinKey!, event.target.value)}
onBlur={(event) => syncField(field.latinKey!, event.target.value)}
onCompositionEnd={(event) => syncField(field.latinKey!, event.currentTarget.value)}
placeholder={field.latinLabel ?? field.latinKey}
readOnly={readOnly}
/>
</div>
) : null}
</div>
);
})}
</form>
{!readOnly ? (
<Button
type="button"
className="w-full rounded-[18px] py-6"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void handleSave()}
disabled={isSaving}
>
{isSaving ? 'Сохраняем...' : 'Сохранить'}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import * as React from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { fetchDocumentPhotoUrl } from '@/lib/api';
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
interface DocumentPhotoGalleryProps {
userId: string;
token: string | null;
storageKeys: string[];
readOnly?: boolean;
onRemove?: (storageKey: string) => void;
}
export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onRemove }: DocumentPhotoGalleryProps) {
const { isPinLocked } = useAuth();
const [urls, setUrls] = React.useState<Record<string, string>>({});
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
const [viewerOpen, setViewerOpen] = React.useState(false);
const [viewerIndex, setViewerIndex] = React.useState(0);
React.useEffect(() => {
if (!token || storageKeys.length === 0 || isPinLocked) {
setUrls({});
return;
}
let cancelled = false;
setLoadingKeys(new Set(storageKeys));
void Promise.all(
storageKeys.map(async (storageKey) => {
try {
const response = await fetchDocumentPhotoUrl(userId, storageKey, token);
return { storageKey, accessUrl: response.accessUrl };
} catch {
return { storageKey, accessUrl: '' };
}
})
).then((results) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const item of results) {
if (item.accessUrl) next[item.storageKey] = item.accessUrl;
}
setUrls(next);
setLoadingKeys(new Set());
});
return () => {
cancelled = true;
};
}, [isPinLocked, storageKeys.join('|'), token, userId]);
const resolvedImages = storageKeys
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
.filter((item) => Boolean(item.url));
if (storageKeys.length === 0) return null;
return (
<>
<div className="grid grid-cols-2 gap-3">
{storageKeys.map((storageKey) => (
<div key={storageKey} className="relative">
<DocumentPhotoThumbnail
url={urls[storageKey]}
loading={loadingKeys.has(storageKey)}
onClick={() => {
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
if (resolvedIndex >= 0) {
setViewerIndex(resolvedIndex);
setViewerOpen(true);
}
}}
/>
{!readOnly && onRemove ? (
<button
type="button"
onClick={() => onRemove(storageKey)}
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
aria-label="Удалить фото"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
{loadingKeys.has(storageKey) ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40">
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
</div>
) : null}
</div>
))}
</div>
<DocumentFullscreenViewer
open={viewerOpen}
onOpenChange={setViewerOpen}
initialIndex={viewerIndex}
images={resolvedImages}
/>
</>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DocumentFullscreenViewerProps {
images: { storageKey: string; url: string }[];
initialIndex?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
const [index, setIndex] = React.useState(initialIndex);
React.useEffect(() => {
if (open) setIndex(initialIndex);
}, [initialIndex, open]);
React.useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onOpenChange(false);
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1));
if (event.key === 'ArrowRight') setIndex((current) => Math.min(images.length - 1, current + 1));
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [images.length, onOpenChange, open]);
if (!open || images.length === 0) return null;
const current = images[index];
return (
<div className="fixed inset-0 z-[300] flex flex-col bg-black/95">
<div className="flex items-center justify-between px-4 py-3 text-white">
<span className="text-sm text-white/80">
{index + 1} / {images.length}
</span>
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-white/10" aria-label="Закрыть">
<X className="h-5 w-5" />
</button>
</div>
<div className="relative flex flex-1 items-center justify-center px-4 pb-6">
{images.length > 1 ? (
<button
type="button"
disabled={index === 0}
onClick={() => setIndex((value) => Math.max(0, value - 1))}
className="absolute left-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
aria-label="Предыдущее фото"
>
<ChevronLeft className="h-6 w-6" />
</button>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={current.url} alt="Фото документа" className="max-h-[calc(100vh-120px)] max-w-full object-contain" />
{images.length > 1 ? (
<button
type="button"
disabled={index === images.length - 1}
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
className="absolute right-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
aria-label="Следующее фото"
>
<ChevronRight className="h-6 w-6" />
</button>
) : null}
</div>
</div>
);
}
interface DocumentPhotoThumbnailProps {
url?: string;
loading?: boolean;
className?: string;
onClick?: () => void;
}
export function DocumentPhotoThumbnail({ url, loading, className, onClick }: DocumentPhotoThumbnailProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group relative aspect-[4/3] overflow-hidden rounded-2xl border border-[#eceef4] bg-[#f4f5f8] transition hover:border-[#20212b]',
className
)}
>
{loading ? <div className="h-full w-full animate-pulse bg-[#eceef4]" /> : null}
{!loading && url ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="Фото документа" className="h-full w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20">
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
</div>
</>
) : null}
{!loading && !url ? <div className="flex h-full items-center justify-center text-xs text-[#667085]">Нет превью</div> : null}
</button>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FileText } from 'lucide-react';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { getDocumentType, indexDocumentsByType, type DocumentTypeCode } from '@/lib/document-catalog';
import { apiFetch, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
export function UserDocumentsDialog({
open,
onOpenChange,
targetUserId,
targetUserName,
token
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetUserId: string;
targetUserName: string;
token: string | null;
}) {
const [documents, setDocuments] = useState<UserDocument[]>([]);
const [loading, setLoading] = useState(false);
const [activeType, setActiveType] = useState<DocumentTypeCode | null>(null);
const [selectedDocument, setSelectedDocument] = useState<UserDocument | null>(null);
const loadDocuments = useCallback(async () => {
if (!token || !targetUserId) return;
setLoading(true);
try {
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${targetUserId}`, {}, token);
setDocuments(response.documents ?? []);
} finally {
setLoading(false);
}
}, [targetUserId, token]);
useEffect(() => {
if (open) void loadDocuments();
}, [loadDocuments, open]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openDocument(type: DocumentTypeCode) {
setSelectedDocument(documentsByType.get(type) ?? null);
setActiveType(type);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Документы пользователя</DialogTitle>
<p className="text-sm text-[#667085]">{targetUserName}</p>
</DialogHeader>
{loading ? <p className="py-8 text-center text-sm text-[#667085]">Загрузка документов...</p> : null}
{!loading && documents.length === 0 ? (
<p className="py-8 text-center text-sm text-[#667085]">У пользователя нет сохранённых документов</p>
) : null}
<div className="space-y-2">
{documents.map((document) => {
const config = getDocumentType(document.type);
if (!config) return null;
const Icon = config.icon;
return (
<button
key={document.id}
type="button"
onClick={() => openDocument(document.type as DocumentTypeCode)}
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-left transition hover:bg-[#eceef4]"
>
<div className={cn('flex h-10 w-10 items-center justify-center rounded-xl', config.accent)}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{config.label}</div>
<div className="truncate text-sm text-[#667085]">{document.number}</div>
</div>
<FileText className="h-4 w-4 text-[#a8adbc]" />
</button>
);
})}
</div>
</DialogContent>
</Dialog>
{activeType ? (
<DocumentFormDialog
open={Boolean(activeType)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setActiveType(null);
setSelectedDocument(null);
}
}}
documentType={activeType}
userId={targetUserId}
token={token}
existing={selectedDocument}
onSaved={loadDocuments}
readOnly
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,805 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
BarChart3,
Camera,
FileText,
Loader2,
Mic,
Paperclip,
Plus,
Send,
Smile,
UserPlus,
Volume2,
VolumeX
} from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
apiFetch,
ChatMessage,
ChatRoom,
createChatRoom,
FamilyGroup,
fetchChatMessages,
fetchChatRooms,
fetchFamilyGroup,
getApiErrorMessage,
sendChatMessage,
sendFamilyInvite,
setChatRoomMuted,
updateFamilyGroup,
voteChatPoll
} from '@/lib/api';
import { cn } from '@/lib/utils';
import {
createBlobObjectUrl,
fetchAuthenticatedMediaBlob,
mediaExpiresAtMs,
revokeBlobObjectUrl,
shouldRefreshMedia,
triggerBlobDownload
} from '@/lib/authenticated-media';
import {
detectChatMessageType,
fileIconLabel,
formatFileSize,
parseMessageMetadata,
resolveChatContentType
} from '@/lib/chat-media';
const EMOJIS = ['😀', '😂', '❤️', '👍', '🎉', '🔥', '😍', '🙏', '👋', '🤔', '😢', '😎'];
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
}
interface LoadedChatMedia {
blobUrl: string;
expiresAt: number;
fileName?: string;
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!hasAvatar || !token) return;
apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token)
.then((response) => setUrl(response.accessUrl))
.catch(() => setUrl(null));
}, [groupId, hasAvatar, token]);
return (
<Avatar className="h-11 w-11">
{url ? <AvatarImage src={url} alt={name} /> : null}
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
export function FamilyGroupView({ groupId }: { groupId: string }) {
const router = useRouter();
const { user, token } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const { subscribe } = useRealtime();
const [group, setGroup] = useState<FamilyGroup | null>(null);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [activeRoomId, setActiveRoomId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const [pollOpen, setPollOpen] = useState(false);
const [inviteTarget, setInviteTarget] = useState('');
const [renameValue, setRenameValue] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState(['', '']);
const [emojiOpen, setEmojiOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [mediaUrls, setMediaUrls] = useState<Record<string, LoadedChatMedia>>({});
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingStartedAtRef = useRef<number | null>(null);
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
const avatarInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
mediaUrlsRef.current = mediaUrls;
}, [mediaUrls]);
const appendMessage = useCallback((message: ChatMessage) => {
setMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message]));
}, []);
const activeRoom = useMemo(() => rooms.find((room) => room.id === activeRoomId) ?? null, [activeRoomId, rooms]);
const myMembership = activeRoom?.members.find((member) => member.userId === user?.id);
const loadGroup = useCallback(async () => {
if (!token || isPinLocked) return;
const [groupResponse, roomsResponse] = await Promise.all([fetchFamilyGroup(groupId, token), fetchChatRooms(groupId, token)]);
setGroup(groupResponse);
setRenameValue(groupResponse.name);
const roomList = roomsResponse.rooms ?? [];
setRooms(roomList);
setActiveRoomId((current) => current ?? roomList[0]?.id ?? null);
}, [groupId, isPinLocked, token]);
const loadMessages = useCallback(async (roomId: string) => {
if (!token || isPinLocked) return;
const response = await fetchChatMessages(roomId, token);
setMessages(response.messages ?? []);
}, [isPinLocked, token]);
useEffect(() => {
if (!isReady || !token || isPinLocked) return;
setLoading(true);
loadGroup()
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка'))
.finally(() => setLoading(false));
}, [isPinLocked, isReady, loadGroup, showToast, token]);
useEffect(() => {
if (!activeRoomId) return;
void loadMessages(activeRoomId);
}, [activeRoomId, loadMessages]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
return subscribe((event) => {
if (event.type !== 'chat_message') return;
const payload = event.payload;
if (!payload?.roomId || payload.roomId !== activeRoomId) return;
if (payload.message) {
appendMessage(payload.message);
} else {
void loadMessages(activeRoomId!);
}
void loadGroup();
});
}, [activeRoomId, appendMessage, loadGroup, loadMessages, subscribe]);
const loadChatMedia = useCallback(
async (message: ChatMessage, force = false) => {
if (!token || !activeRoomId || !message.storageKey) return;
const storageKey = message.storageKey;
const existing = mediaUrlsRef.current[storageKey];
if (!force && existing && !shouldRefreshMedia(existing.expiresAt)) return;
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || undefined;
const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
try {
const access = await apiFetch<{ accessUrl: string; expiresAt: string }>(
`/media/chat/${activeRoomId}/media/url?${query.toString()}`,
{},
token
);
const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token);
const blobUrl = createBlobObjectUrl(blob);
setMediaUrls((current) => {
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
return {
...current,
[storageKey]: {
blobUrl,
expiresAt: mediaExpiresAtMs(access.expiresAt),
fileName
}
};
});
} catch {
// ignore failed media loads in background
}
},
[activeRoomId, token]
);
useEffect(() => {
if (!token || !activeRoomId) return;
messages.forEach((message) => {
if (message.storageKey) void loadChatMedia(message);
});
}, [activeRoomId, loadChatMedia, messages, token]);
useEffect(() => {
if (!token || !activeRoomId) return;
const timer = window.setInterval(() => {
messages.forEach((message) => {
if (!message.storageKey) return;
const cached = mediaUrlsRef.current[message.storageKey];
if (cached && shouldRefreshMedia(cached.expiresAt)) {
void loadChatMedia(message, true);
}
});
}, 60_000);
return () => window.clearInterval(timer);
}, [activeRoomId, loadChatMedia, messages, token]);
useEffect(() => {
return () => {
Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl));
};
}, []);
async function downloadChatFile(message: ChatMessage) {
if (!token || !activeRoomId || !message.storageKey) return;
const storageKey = message.storageKey;
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'file';
setDownloadingKey(storageKey);
try {
let blob: Blob | null = null;
const cached = mediaUrls[storageKey];
if (cached?.blobUrl) {
blob = await fetch(cached.blobUrl).then((response) => response.blob());
} else {
const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
const access = await apiFetch<{ accessUrl: string }>(
`/media/chat/${activeRoomId}/media/url?${query.toString()}`,
{},
token
);
blob = await fetchAuthenticatedMediaBlob(`${access.accessUrl}?download=1`, token);
}
if (!blob) {
throw new Error('Не удалось получить файл');
}
triggerBlobDownload(blob, fileName);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось скачать файл') ?? 'Ошибка');
} finally {
setDownloadingKey(null);
}
}
async function saveRename() {
if (!token || !renameValue.trim()) return;
try {
const updated = await updateFamilyGroup(groupId, renameValue.trim(), token);
setGroup(updated);
showToast('Название семьи обновлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось переименовать семью') ?? 'Ошибка');
}
}
async function uploadFamilyAvatar(file: File) {
if (!token) return;
try {
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/families/${groupId}/avatar/upload-url`,
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
token
);
await fetch(presigned.uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
await apiFetch(`/media/families/${groupId}/avatar/confirm`, {
method: 'POST',
body: JSON.stringify({ storageKey: presigned.storageKey })
}, token);
await loadGroup();
showToast('Аватар семьи обновлён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить аватар') ?? 'Ошибка');
}
}
async function submitInvite() {
if (!token || !inviteTarget.trim()) return;
try {
await sendFamilyInvite(groupId, inviteTarget.trim(), token);
setInviteOpen(false);
setInviteTarget('');
showToast('Приглашение отправлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка');
}
}
async function submitCreateRoom() {
if (!token || !newRoomName.trim()) return;
try {
const room = await createChatRoom(groupId, newRoomName.trim(), selectedMembers, token);
setRooms((current) => [...current, room]);
setActiveRoomId(room.id);
setCreateRoomOpen(false);
setNewRoomName('');
setSelectedMembers([]);
showToast('Групповой чат создан');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать чат') ?? 'Ошибка');
}
}
async function sendText(type: string = 'TEXT') {
if (!token || !activeRoomId || sending) return;
const content = draft.trim();
if (!content && type === 'TEXT') return;
setSending(true);
try {
const message = await sendChatMessage(activeRoomId, { type, content }, token);
appendMessage(message);
setDraft('');
setEmojiOpen(false);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
if (!token || !activeRoomId) return;
setSending(true);
try {
const contentType = resolveChatContentType(file);
const messageType = detectChatMessageType(file, { voice: options?.voice });
const metadata = {
fileName: file.name,
fileSize: file.size,
...(options?.durationMs ? { durationMs: options.durationMs } : {})
};
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/chat/${activeRoomId}/media/upload-url`,
{
method: 'POST',
body: JSON.stringify({ contentType, fileName: file.name })
},
token
);
await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
const message = await sendChatMessage(
activeRoomId,
{
type: messageType,
storageKey: presigned.storageKey,
mimeType: contentType,
content:
messageType === 'VOICE'
? 'Голосовое сообщение'
: messageType === 'FILE'
? file.name
: undefined,
metadataJson: JSON.stringify(metadata)
},
token
);
appendMessage(message);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function submitPoll() {
if (!token || !activeRoomId) return;
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
if (!pollQuestion.trim() || options.length < 2) {
showToast('Укажите вопрос и минимум 2 варианта');
return;
}
setSending(true);
try {
const message = await sendChatMessage(
activeRoomId,
{ type: 'POLL', poll: { question: pollQuestion.trim(), options } },
token
);
appendMessage(message);
setPollOpen(false);
setPollQuestion('');
setPollOptions(['', '']);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать опрос') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function toggleMute() {
if (!token || !activeRoomId || !activeRoom) return;
const muted = !myMembership?.notificationsMuted;
await setChatRoomMuted(activeRoomId, muted, token);
setRooms((current) =>
current.map((room) =>
room.id === activeRoomId
? {
...room,
members: room.members.map((member) =>
member.userId === user?.id ? { ...member, notificationsMuted: muted } : member
)
}
: room
)
);
showToast(muted ? 'Уведомления чата выключены' : 'Уведомления чата включены');
}
async function startVoiceRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : undefined;
recordingStartedAtRef.current = null;
const blob = new Blob(chunks, { type: 'audio/webm' });
void uploadChatFile(new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' }), {
voice: true,
durationMs
});
};
mediaRecorderRef.current = recorder;
recordingStartedAtRef.current = Date.now();
recorder.start();
setRecording(true);
} catch {
showToast('Не удалось получить доступ к микрофону');
}
}
function stopVoiceRecording() {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current = null;
setRecording(false);
}
if (!isReady || loading) {
return <div className="py-20 text-center text-[#667085]">Загрузка семьи...</div>;
}
return (
<div className="flex h-[calc(100vh-120px)] min-h-[640px] overflow-hidden rounded-[28px] border border-[#eceef4] bg-[#eef2f8]">
<aside className="flex w-[300px] shrink-0 flex-col border-r border-[#e4e8ef] bg-white">
<div className="border-b border-[#eceef4] p-4">
<button type="button" className="mb-3 flex items-center gap-2 text-sm text-[#667085]" onClick={() => router.push('/family')}>
<ArrowLeft className="h-4 w-4" />
Все семьи
</button>
<div className="flex items-center gap-3">
<button type="button" onClick={() => avatarInputRef.current?.click()}>
<FamilyAvatar groupId={groupId} name={group?.name ?? 'С'} hasAvatar={group?.hasAvatar} token={token} />
</button>
<div className="min-w-0 flex-1">
<Input value={renameValue} onChange={(event) => setRenameValue(event.target.value)} className="h-9" />
<Button size="sm" variant="secondary" className="mt-2 h-8 rounded-xl" onClick={() => void saveRename()}>
Сохранить название
</Button>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="secondary" className="flex-1 rounded-xl" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-1 h-4 w-4" />
Пригласить
</Button>
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => avatarInputRef.current?.click()}>
<Camera className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<p className="text-sm font-semibold text-[#667085]">Чаты</p>
<Button size="sm" variant="ghost" className="h-8 w-8 rounded-xl p-0" onClick={() => setCreateRoomOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => setActiveRoomId(room.id)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition hover:bg-[#f7f9fc]',
activeRoomId === room.id && 'bg-[#3390ec]/10'
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-sm font-semibold text-white">
{room.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-medium">{room.name}</p>
{room.lastMessage ? (
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatMessageTime(room.lastMessage.createdAt)}</span>
) : null}
</div>
<p className="truncate text-sm text-[#667085]">
{room.lastMessage?.content ?? (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений')}
</p>
</div>
</button>
))}
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col bg-[#e6ebf2]">
{activeRoom ? (
<>
<div className="flex items-center justify-between border-b border-[#dce3ec] bg-white px-4 py-3">
<div>
<h2 className="font-semibold">{activeRoom.name}</h2>
<p className="text-xs text-[#667085]">{activeRoom.members.length} участников</p>
</div>
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => void toggleMute()}>
{myMembership?.notificationsMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{messages.map((message) => {
const mine = message.senderId === user?.id;
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm',
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]'
)}
>
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{message.type === 'POLL' && message.poll ? (
<div className="space-y-2">
<p className="font-medium">{message.poll.question}</p>
{message.poll.options.map((option) => {
const total = message.poll!.options.reduce((sum, item) => sum + item.voteCount, 0) || 1;
const percent = Math.round((option.voteCount / total) * 100);
return (
<button
key={option.id}
type="button"
className="relative w-full overflow-hidden rounded-xl border border-[#dce3ec] px-3 py-2 text-left text-sm"
onClick={() => void voteChatPoll(message.id, [option.id], token).then((updated) => {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
})}
>
<div className="absolute inset-y-0 left-0 bg-[#3390ec]/15" style={{ width: `${percent}%` }} />
<span className="relative flex justify-between gap-2">
<span>{option.text}</span>
<span>{percent}%</span>
</span>
</button>
);
})}
</div>
) : message.storageKey ? (
(() => {
const media = mediaUrls[message.storageKey];
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'Файл';
if (!media?.blobUrl) {
return (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка файла...
</div>
);
}
if (message.type === 'IMAGE') {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full rounded-xl object-cover" />
);
}
if (message.type === 'VOICE' || message.type === 'AUDIO') {
return (
<div className="flex min-w-[220px] items-center gap-3 rounded-xl bg-black/5 px-3 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-white">
{message.type === 'VOICE' ? <Mic className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-[#667085]">
{message.type === 'VOICE' ? 'Голосовое сообщение' : 'Аудиофайл'}
</p>
<audio controls preload="metadata" src={media.blobUrl} className="mt-1 h-8 w-full max-w-[240px]" />
</div>
</div>
);
}
return (
<button
type="button"
disabled={downloadingKey === message.storageKey}
onClick={() => void downloadChatFile(message)}
className="flex min-w-[220px] items-center gap-3 rounded-xl bg-black/5 px-3 py-2 text-left transition hover:bg-black/10 disabled:opacity-70"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-xs font-bold text-white">
{downloadingKey === message.storageKey ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
fileIconLabel(fileName, message.mimeType)
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{fileName}</p>
<p className="text-xs text-[#667085]">{formatFileSize(meta.fileSize) || 'Документ'}</p>
</div>
<FileText className="h-4 w-4 shrink-0 text-[#667085]" />
</button>
);
})()
) : (
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">{message.content}</p>
)}
<p className={cn('mt-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
{formatMessageTime(message.createdAt)}
</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-[#dce3ec] bg-white px-3 py-3">
{emojiOpen ? (
<div className="mb-2 flex flex-wrap gap-1 rounded-2xl bg-[#f4f5f8] p-2">
{EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
className="rounded-lg px-2 py-1 text-xl hover:bg-white"
onClick={() => setDraft((current) => `${current}${emoji}`)}
>
{emoji}
</button>
))}
</div>
) : null}
<div className="flex items-end gap-2">
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => fileInputRef.current?.click()}>
<Paperclip className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setPollOpen(true)}>
<BarChart3 className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setEmojiOpen((value) => !value)}>
<Smile className="h-4 w-4" />
</Button>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Сообщение"
rows={1}
className="max-h-32 min-h-[44px] flex-1 resize-none rounded-[20px] border border-[#dce3ec] bg-[#f4f5f8] px-4 py-3 text-[15px] outline-none focus:border-[#3390ec]"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void sendText();
}
}}
/>
{draft.trim() ? (
<Button className="h-11 w-11 rounded-full p-0" disabled={sending} onClick={() => void sendText()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
) : (
<Button
className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')}
onMouseDown={() => void startVoiceRecording()}
onMouseUp={stopVoiceRecording}
onMouseLeave={stopVoiceRecording}
onTouchStart={() => void startVoiceRecording()}
onTouchEnd={stopVoiceRecording}
>
<Mic className="h-4 w-4" />
</Button>
)}
</div>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center text-[#667085]">Выберите чат</div>
)}
</section>
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadFamilyAvatar(file);
event.target.value = '';
}} />
<input ref={fileInputRef} type="file" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadChatFile(file);
event.target.value = '';
}} />
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<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>
</DialogContent>
</Dialog>
<Dialog open={createRoomOpen} onOpenChange={setCreateRoomOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Новый групповой чат</DialogTitle></DialogHeader>
<Input placeholder="Название чата" value={newRoomName} onChange={(event) => setNewRoomName(event.target.value)} />
<div className="mt-3 space-y-2">
<p className="text-sm font-medium">Участники</p>
{(group?.members ?? []).filter((member) => member.userId !== user?.id).map((member) => (
<label key={member.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
<input
type="checkbox"
checked={selectedMembers.includes(member.userId)}
onChange={(event) => {
setSelectedMembers((current) =>
event.target.checked ? [...current, member.userId] : current.filter((id) => id !== member.userId)
);
}}
/>
{member.displayName}
</label>
))}
</div>
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitCreateRoom()}>Создать чат</Button>
</DialogContent>
</Dialog>
<Dialog open={pollOpen} onOpenChange={setPollOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Создать опрос</DialogTitle></DialogHeader>
<Input placeholder="Вопрос" value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} />
<div className="mt-3 space-y-2">
{pollOptions.map((option, index) => (
<Input
key={index}
placeholder={`Вариант ${index + 1}`}
value={option}
onChange={(event) => setPollOptions((current) => current.map((item, i) => (i === index ? event.target.value : item)))}
/>
))}
</div>
<Button variant="secondary" className="mt-2 rounded-xl" onClick={() => setPollOptions((current) => [...current, ''])}>
Добавить вариант
</Button>
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitPoll()}>Отправить опрос</Button>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export function ActionTile({
icon: Icon,
label,
description,
className,
onClick
}: {
icon: LucideIcon;
label: string;
description?: string;
className?: string;
onClick?: () => void;
}) {
const content = (
<>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white">
<Icon className="h-5 w-5" />
</div>
<div className="max-w-[92px] text-[12px] font-medium leading-tight">{label}</div>
{description ? <p className="line-clamp-2 text-[11px] leading-tight text-[#667085]">{description}</p> : null}
</>
);
const baseClass = cn('flex min-h-[92px] flex-col items-center justify-center gap-2 rounded-[22px] bg-[#f4f5f8] p-3 text-center', className);
if (onClick) {
return (
<button type="button" onClick={onClick} className={cn(baseClass, 'transition hover:bg-[#eceef4]')}>
{content}
</button>
);
}
return <div className={baseClass}>{content}</div>;
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-4 mt-8 text-[26px] font-medium tracking-tight">{children} </h2>;
}

View File

@@ -0,0 +1,24 @@
'use client';
import { ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PublicUser } from '@/lib/api';
export function AdminBadge({ user, className }: { user: PublicUser; className?: string }) {
if (!user.isSuperAdmin && !user.canAccessAdmin) return null;
const label = user.isSuperAdmin ? 'Супер-админ' : user.roles?.includes('admin') ? 'Администратор' : 'Админ';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold',
user.isSuperAdmin ? 'bg-gradient-to-r from-[#20212b] to-[#3d4255] text-white shadow-sm' : 'bg-[#eef4ff] text-[#1d4ed8]',
className
)}
>
{user.isSuperAdmin ? <Sparkles className="h-3.5 w-3.5" /> : <ShieldCheck className="h-3.5 w-3.5" />}
{label}
</span>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { Boxes, Settings2, ShieldCheck, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PublicUser } from '@/lib/api';
const links = [
{ href: '/admin/users', label: 'Пользователи', icon: Users, permission: 'canViewUsers' as const, altPermission: 'canManageUsers' as const },
{ href: '/admin/oauth', label: 'OAuth', icon: Boxes, permission: 'canManageOAuth' as const },
{ href: '/admin/rbac', label: 'Роли', icon: ShieldCheck, permission: 'canManageRoles' as const, superAdminOnly: true },
{ href: '/admin/settings', label: 'Настройки', icon: Settings2, permission: 'canManageSettings' as const }
];
export function AdminNav({ active, user }: { active: string; user: PublicUser }) {
const visible = links.filter((link) => {
if (link.superAdminOnly && !user.canManageRoles) return false;
return Boolean(user[link.permission]) || (link.altPermission ? Boolean(user[link.altPermission]) : false) || user.isSuperAdmin;
});
return (
<nav className="mb-8 flex flex-wrap gap-2">
{visible.map((link) => {
const isActive = active === link.href;
return (
<Link
key={link.href}
href={link.href}
className={cn(
'inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-black !text-white shadow-sm' : 'bg-[#f4f5f8] text-[#1f2430] hover:bg-[#eceef4]'
)}
>
<link.icon className={cn('h-4 w-4', isActive ? '!text-white' : 'text-current')} />
<span className={isActive ? '!text-white' : undefined}>{link.label}</span>
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/id/auth-provider';
import { AdminBadge } from '@/components/id/admin-badge';
import { AdminNav } from '@/components/id/admin-nav';
import { IdShell } from '@/components/id/shell';
export function AdminShell({ active, children }: { active: string; children: React.ReactNode }) {
const router = useRouter();
const { user, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && user && !user.canAccessAdmin) {
router.replace('/');
}
}, [isLoading, router, user]);
if (isLoading || !user) {
return (
<IdShell active={active} wide>
<div className="py-20 text-center text-[#667085]">Загрузка админ-панели...</div>
</IdShell>
);
}
if (!user.canAccessAdmin) {
return null;
}
return (
<IdShell active="/admin/users" wide>
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div className="mb-2 flex items-center gap-2">
<AdminBadge user={user} />
</div>
<h1 className="text-4xl font-medium tracking-tight">Админ-панель</h1>
<p className="mt-2 text-[#667085]">Управление пользователями, OAuth-приложениями и правами доступа</p>
</div>
</div>
<AdminNav active={active} user={user} />
{children}
</IdShell>
);
}

View File

@@ -0,0 +1,473 @@
'use client';
import * as React from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
apiFetch,
ApiError,
AUTH_REFRESH_KEY,
AUTH_SESSION_KEY,
AUTH_TOKEN_KEY,
AUTH_USER_CACHE_KEY,
AuthSessionResponse,
AuthTokens,
fetchAuthSession,
getDeviceFingerprint,
IdentifyResponse,
getApiErrorMessage,
isPinRequiredError,
OtpSendResponse,
PasswordlessAuthResponse,
PinVerificationResponse,
PublicUser,
refreshAuthSession,
resetPinRequiredNotification,
setPinRequiredHandler
} from '@/lib/api';
import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
interface AuthContextValue {
user: PublicUser | null;
token: string | null;
isLoading: boolean;
isPinLocked: boolean;
hasStoredSession: boolean;
login: (login: string, password: string) => Promise<AuthTokens>;
identifyLogin: (login: string) => Promise<IdentifyResponse>;
sendLoginOtp: (recipient: string, channel?: string) => Promise<string>;
verifyLoginOtp: (recipient: string, code: string) => Promise<PasswordlessAuthResponse>;
loginWithPassword: (login: string, password: string) => Promise<AuthTokens>;
loginWithLdap: (username: string, password: string) => Promise<AuthTokens>;
completePin: (sessionId: string, pin: string) => Promise<void>;
register: (data: { displayName: string; login: string; password: string }) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => void;
}
const AuthContext = React.createContext<AuthContextValue | null>(null);
function cacheUserProfile(user: PublicUser) {
window.localStorage.setItem(AUTH_USER_CACHE_KEY, JSON.stringify(user));
}
function readCachedUserProfile(): PublicUser | null {
const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PublicUser;
} catch {
return null;
}
}
function persistPartialAuth(auth: AuthTokens) {
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
cacheUserProfile(auth.user);
}
function applySessionState(
session: AuthSessionResponse,
setters: {
setUser: React.Dispatch<React.SetStateAction<PublicUser | null>>;
setToken: React.Dispatch<React.SetStateAction<string | null>>;
activatePinLock: (sessionId: string) => void;
clearPinLock: () => void;
}
) {
setters.setUser(session.user);
cacheUserProfile(session.user);
if (session.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, session.sessionId);
}
if (session.requiresPin) {
setters.activatePinLock(session.sessionId ?? '');
} else {
setters.clearPinLock();
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { showToast } = useToast();
const [token, setToken] = React.useState<string | null>(null);
const [user, setUser] = React.useState<PublicUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [isPinLocked, setIsPinLocked] = React.useState(false);
const [hasStoredSession, setHasStoredSession] = React.useState(false);
const [lockedSessionId, setLockedSessionId] = React.useState<string | null>(null);
const [pinSubmitting, setPinSubmitting] = React.useState(false);
const [pinError, setPinError] = React.useState<string | null>(null);
const isPinLockedRef = React.useRef(false);
const refreshInFlightRef = React.useRef<Promise<void> | null>(null);
React.useEffect(() => {
isPinLockedRef.current = isPinLocked;
}, [isPinLocked]);
const clearPinLock = React.useCallback(() => {
setIsPinLocked(false);
setLockedSessionId(null);
setPinError(null);
resetPinRequiredNotification();
}, []);
const activatePinLock = React.useCallback((sessionId: string) => {
const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY);
if (!resolvedSessionId) return;
setLockedSessionId(resolvedSessionId);
setIsPinLocked(true);
setPinError(null);
setHasStoredSession(true);
const cachedUser = readCachedUserProfile();
if (cachedUser) {
setUser(cachedUser);
}
}, []);
const saveSession = React.useCallback(
(auth: AuthTokens) => {
window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken);
window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken);
window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId);
setToken(auth.accessToken);
setUser(auth.user);
cacheUserProfile(auth.user);
setHasStoredSession(true);
clearPinLock();
},
[clearPinLock]
);
const logout = React.useCallback(() => {
window.localStorage.removeItem(AUTH_TOKEN_KEY);
window.localStorage.removeItem(AUTH_REFRESH_KEY);
window.localStorage.removeItem(AUTH_SESSION_KEY);
window.localStorage.removeItem(AUTH_USER_CACHE_KEY);
setToken(null);
setUser(null);
setHasStoredSession(false);
clearPinLock();
router.push('/auth/login');
}, [clearPinLock, router]);
const refreshProfile = React.useCallback(async () => {
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const task = (async () => {
const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
setHasStoredSession(Boolean(refreshToken));
if (!currentToken && !refreshToken) {
setIsLoading(false);
return;
}
try {
if (currentToken) {
setToken(currentToken);
try {
const session = await fetchAuthSession(currentToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
return;
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') {
throw error;
}
}
}
if (refreshToken) {
const refreshed = await refreshAuthSession();
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
setToken(refreshed.accessToken);
}
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.requiresPin) {
if (refreshed.user) {
setUser(refreshed.user);
cacheUserProfile(refreshed.user);
}
activatePinLock(refreshed.sessionId ?? '');
return;
}
if (refreshed.accessToken) {
const session = await fetchAuthSession(refreshed.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
return;
}
}
throw new ApiError('Сессия недействительна', 401);
} catch (error) {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (refreshToken && error instanceof ApiError && error.status === 403) {
activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return;
}
if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) {
logout();
return;
}
const message = getApiErrorMessage(error, 'Не удалось загрузить профиль');
if (message) showToast(message);
logout();
} finally {
setIsLoading(false);
refreshInFlightRef.current = null;
}
})();
refreshInFlightRef.current = task;
return task;
}, [activatePinLock, clearPinLock, logout, showToast]);
React.useEffect(() => {
setPinRequiredHandler(activatePinLock);
return () => setPinRequiredHandler(null);
}, [activatePinLock]);
React.useEffect(() => {
void refreshProfile();
}, [refreshProfile]);
React.useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return;
if (isPinLockedRef.current) return;
if (!window.localStorage.getItem(AUTH_REFRESH_KEY)) return;
void refreshProfile();
}
function handleAuthRefreshed(event: Event) {
const detail = (event as CustomEvent<{ accessToken?: string; user?: PublicUser }>).detail;
if (detail.accessToken) {
setToken(detail.accessToken);
}
if (detail.user) {
setUser(detail.user);
cacheUserProfile(detail.user);
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('lendry:auth-refreshed', handleAuthRefreshed);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('lendry:auth-refreshed', handleAuthRefreshed);
};
}, [refreshProfile]);
const login = React.useCallback(
async (loginValue: string, password: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const identifyLogin = React.useCallback(async (loginValue: string) => {
return apiFetch<IdentifyResponse>('/auth/identify', {
method: 'POST',
body: JSON.stringify({ login: loginValue })
});
}, []);
const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => {
const response = await apiFetch<OtpSendResponse>('/auth/otp/send', {
method: 'POST',
body: JSON.stringify({ recipient, channel })
});
return response.maskedTarget;
}, []);
const verifyLoginOtp = React.useCallback(
async (recipient: string, code: string) => {
const response = await apiFetch<PasswordlessAuthResponse>('/auth/otp/verify', {
method: 'POST',
body: JSON.stringify({
recipient,
code,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (response.auth?.pinVerified) {
saveSession(response.auth);
} else if (response.auth) {
persistPartialAuth(response.auth);
setHasStoredSession(true);
}
return response;
},
[saveSession]
);
const loginWithPassword = React.useCallback(
async (loginValue: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/login/password', {
method: 'POST',
body: JSON.stringify({
login: loginValue,
password: passwordValue,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const loginWithLdap = React.useCallback(
async (username: string, passwordValue: string) => {
const auth = await apiFetch<AuthTokens>('/auth/ldap/login', {
method: 'POST',
body: JSON.stringify({
username,
password: passwordValue,
fingerprint: getDeviceFingerprint(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
})
});
if (auth.pinVerified) {
saveSession(auth);
} else {
persistPartialAuth(auth);
setHasStoredSession(true);
}
return auth;
},
[saveSession]
);
const completePin = React.useCallback(
async (sessionId: string, pin: string) => {
const response = await apiFetch<PinVerificationResponse>('/auth/pin/verify', {
method: 'POST',
body: JSON.stringify({ sessionId, pin })
});
window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken);
window.localStorage.setItem(AUTH_SESSION_KEY, sessionId);
setToken(response.accessToken);
const session = await fetchAuthSession(response.accessToken);
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
if (pathname.startsWith('/auth/')) {
router.replace('/');
}
},
[activatePinLock, clearPinLock, pathname, router]
);
const handlePinUnlock = React.useCallback(
async (pin: string) => {
const sessionId = lockedSessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY);
if (!sessionId) {
setPinError('Сессия не найдена. Войдите снова.');
return;
}
setPinSubmitting(true);
setPinError(null);
try {
await completePin(sessionId, pin);
} catch (error) {
setPinError(error instanceof Error ? error.message : 'Не удалось подтвердить PIN-код');
} finally {
setPinSubmitting(false);
}
},
[completePin, lockedSessionId]
);
const register = React.useCallback(
async (data: { displayName: string; login: string; password: string }) => {
const isEmail = data.login.includes('@');
await apiFetch<PublicUser>('/auth/register', {
method: 'POST',
body: JSON.stringify({
displayName: data.displayName,
password: data.password,
...(isEmail ? { email: data.login } : { phone: data.login })
})
});
await login(data.login, data.password);
},
[login]
);
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isPinLocked,
hasStoredSession,
login,
identifyLogin,
sendLoginOtp,
verifyLoginOtp,
loginWithPassword,
loginWithLdap,
completePin,
register,
refreshProfile,
logout
}}
>
{children}
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
</AuthContext.Provider>
);
}
export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth должен использоваться внутри AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,127 @@
'use client';
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 } from '@/lib/api';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt: string;
}
export function AvatarUpload({
userId,
displayName,
hasAvatar,
token,
onUpdated
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
onUpdated: () => Promise<void>;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const { avatarUrl, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !token) return;
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
showToast('Допустимы только JPEG, PNG, WEBP или GIF');
return;
}
setIsUploading(true);
try {
const presigned = await apiFetch<PresignedUploadResponse>(
'/media/avatars/upload-url',
{
method: 'POST',
body: JSON.stringify({ contentType: file.type })
},
token
);
const uploadResponse = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!uploadResponse.ok) {
throw new Error('Не удалось загрузить файл в хранилище');
}
await apiFetch('/media/avatars/confirm', {
method: 'POST',
body: JSON.stringify({ storageKey: presigned.storageKey })
}, token);
await onUpdated();
await refreshAvatarUrl();
showToast('Аватар обновлён');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить аватар');
if (message) showToast(message);
} finally {
setIsUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="relative inline-flex">
<Avatar className="h-24 w-24 border-4 border-white shadow-xl">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
</Avatar>
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileChange} />
<Button
type="button"
size="sm"
variant="secondary"
className="absolute -bottom-2 left-1/2 -translate-x-1/2 rounded-full px-3 shadow"
disabled={isUploading || !token}
onClick={() => inputRef.current?.click()}
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
{isUploading ? 'Загрузка...' : 'Фото'}
</Button>
</div>
);
}
export function AvatarDisplay({
userId,
displayName,
hasAvatar,
token,
className
}: {
userId: string;
displayName?: string;
hasAvatar?: boolean;
token: string | null;
className?: string;
}) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
return (
<Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { cn } from '@/lib/utils';
import { usePublicSettings } from '@/components/id/public-settings-provider';
function splitProjectName(projectName: string) {
const trimmed = projectName.trim();
const match = trimmed.match(/^(.+?)\s+ID$/i);
if (match) {
return { brand: match[1].trim(), suffix: 'ID' };
}
return { brand: trimmed || 'MVK', suffix: 'ID' };
}
export function BrandLogo({
className,
size = 'md',
variant = 'dark'
}: {
className?: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'dark' | 'light';
}) {
const { projectName } = usePublicSettings();
const { brand, suffix } = splitProjectName(projectName);
const sizeClass = size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-lg' : 'text-xl';
const badgeClass =
variant === 'light'
? 'rounded-full border border-white px-1 text-base'
: 'rounded-full bg-black px-1 text-sm text-white';
return (
<div className={cn('font-bold tracking-tight', sizeClass, className)}>
{brand} <span className={badgeClass}>{suffix}</span>
</div>
);
}
export function ProjectTagline({ className }: { className?: string }) {
const { projectTagline } = usePublicSettings();
return <p className={className}>{projectTagline}</p>;
}

View File

@@ -0,0 +1,62 @@
'use client';
import * as React from 'react';
import { KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
interface PinLockModalProps {
open: boolean;
isSubmitting: boolean;
error?: string | null;
onSubmit: (pin: string) => Promise<void>;
}
export function PinLockModal({ open, isSubmitting, error, onSubmit }: PinLockModalProps) {
const [pin, setPin] = React.useState('');
React.useEffect(() => {
if (open) {
setPin('');
}
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSubmit(pin);
}
return (
<Dialog open={open} onOpenChange={() => undefined}>
<DialogContent className="[&>button]:hidden" onPointerDownOutside={(event) => event.preventDefault()} onEscapeKeyDown={(event) => event.preventDefault()}>
<DialogHeader>
<div className="mx-auto mb-2 flex h-14 w-14 items-center justify-center rounded-full bg-[#f4f5f8]">
<KeyRound className="h-7 w-7 text-[#111]" />
</div>
<DialogTitle className="text-center">Введите PIN-код</DialogTitle>
<p className="text-center text-sm text-[#6b6f7b]">Сессия заблокирована из-за бездействия. Подтвердите PIN, чтобы продолжить работу.</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
autoFocus
className="h-[58px] text-center text-lg tracking-[0.4em]"
placeholder="PIN"
value={pin}
onChange={(event) => setPin(event.target.value)}
required
minLength={4}
maxLength={6}
inputMode="numeric"
autoComplete="one-time-code"
/>
{error ? <p className="text-center text-sm text-red-600">{error}</p> : null}
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={isSubmitting || pin.length < 4}>
{isSubmitting ? 'Проверяем...' : 'Разблокировать'}
</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { useEffect } from 'react';
import { usePublicSettings } from '@/components/id/public-settings-provider';
export function ProjectHead() {
const { projectName, projectTagline } = usePublicSettings();
useEffect(() => {
document.title = projectName;
const meta = document.querySelector('meta[name="description"]');
if (meta) {
meta.setAttribute('content', projectTagline);
}
}, [projectName, projectTagline]);
return null;
}

View File

@@ -0,0 +1,64 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { apiFetch } from '@/lib/api';
interface PublicSettingsContextValue {
projectName: string;
projectTagline: string;
ldapEnabled: boolean;
ldapUseLdaps: boolean;
isLoading: boolean;
refreshPublicSettings: () => Promise<void>;
}
const PublicSettingsContext = createContext<PublicSettingsContextValue>({
projectName: 'MVK ID',
projectTagline: 'Единый аккаунт для сервисов Lendry',
ldapEnabled: false,
ldapUseLdaps: false,
isLoading: true,
refreshPublicSettings: async () => undefined
});
export function PublicSettingsProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true);
const refreshPublicSettings = useCallback(async () => {
try {
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
setSettings(map);
} catch {
// Оставляем последние известные значения.
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refreshPublicSettings();
const onFocus = () => void refreshPublicSettings();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [refreshPublicSettings]);
const value = useMemo(
() => ({
projectName: settings.PROJECT_NAME || 'MVK ID',
projectTagline: settings.PROJECT_TAGLINE || 'Единый аккаунт для сервисов Lendry',
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
isLoading,
refreshPublicSettings
}),
[isLoading, refreshPublicSettings, settings.LDAP_ENABLED, settings.LDAP_USE_LDAPS, settings.PROJECT_NAME, settings.PROJECT_TAGLINE]
);
return <PublicSettingsContext.Provider value={value}>{children}</PublicSettingsContext.Provider>;
}
export function usePublicSettings() {
return useContext(PublicSettingsContext);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { NotificationBell } from '@/components/notifications/notification-bell';
export function IdShell({ active, children, wide = false }: { active: string; children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-white">
<Sidebar active={active} />
<div className="fixed right-4 top-4 z-40 flex items-center gap-2 lg:right-8 lg:top-6">
<NotificationBell />
<UserMenu />
</div>
<main className="px-5 py-8 lg:pl-[170px] lg:pr-8">
<div className={wide ? 'mx-auto max-w-[920px]' : 'mx-auto max-w-[560px]'}>{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { FileText, Heart, Home, LockKeyhole, LogOut, ShieldCheck, UserRound } from 'lucide-react';
import { BrandLogo } from '@/components/id/brand-logo';
import { cn } from '@/lib/utils';
import { useAuth } from './auth-provider';
const baseItems = [
{ href: '/', label: 'Главная', icon: Home },
{ href: '/data', label: 'Данные', icon: UserRound },
{ href: '/documents', label: 'Документы', icon: FileText },
{ href: '/family', label: 'Семья', icon: Heart },
{ href: '/security', label: 'Безопасность', icon: LockKeyhole }
];
export function Sidebar({ active }: { active: string }) {
const { logout, user } = useAuth();
const items = user?.canAccessAdmin
? [...baseItems, { href: '/admin/users', label: 'Админка', icon: ShieldCheck }]
: baseItems;
return (
<aside className="fixed left-0 top-0 hidden h-screen w-[150px] flex-col justify-between border-r border-transparent bg-white px-3 py-7 text-[13px] lg:flex">
<div>
<Link href="/" className="mb-7 block">
<BrandLogo size="lg" variant="dark" />
</Link>
<nav className="space-y-1.5">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-xl px-3 py-2 text-[#1f2430] hover:bg-[#f4f5f8]',
(active === item.href || (item.href.startsWith('/admin') && active.startsWith('/admin'))) && 'bg-[#f4f5f8]'
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</div>
<div className="space-y-2 text-[11px] text-[#667085]">
{user ? <p className="truncate font-medium text-[#1f2430]">{user.displayName}</p> : null}
<button type="button" onClick={logout} className="flex items-center gap-2 rounded-lg px-2 py-1 text-[#1f2430] hover:bg-[#f4f5f8]">
<LogOut className="h-3.5 w-3.5" />
Выйти
</button>
<p>Русский</p>
<p>Справка</p>
<p>© 2026 Lendry</p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import { Toast, ToastProvider as RadixToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
interface ToastState {
id: number;
title: string;
}
const ToastContext = React.createContext<{ showToast: (title: string) => void } | null>(null);
const PIN_TOAST_MESSAGE = 'Требуется подтверждение PIN-кода';
export function AppToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<ToastState[]>([]);
const recentMessagesRef = React.useRef<Map<string, number>>(new Map());
const showToast = React.useCallback((title: string) => {
if (title === PIN_TOAST_MESSAGE) return;
const now = Date.now();
const lastShown = recentMessagesRef.current.get(title);
if (lastShown && now - lastShown < 4000) return;
recentMessagesRef.current.set(title, now);
const id = now;
setToasts((current) => {
if (current.some((toast) => toast.title === title)) return current;
return [...current, { id, title }];
});
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4500);
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
<RadixToastProvider swipeDirection="right">
{children}
{toasts.map((toast) => (
<Toast key={toast.id} open>
<ToastTitle>{toast.title}</ToastTitle>
</Toast>
))}
<ToastViewport />
</RadixToastProvider>
</ToastContext.Provider>
);
}
export function useToast() {
const context = React.useContext(ToastContext);
if (!context) {
throw new Error('useToast должен использоваться внутри AppToastProvider');
}
return context;
}

View File

@@ -0,0 +1,125 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ChevronRight,
FileText,
Heart,
LockKeyhole,
LogOut,
ShieldCheck,
Smartphone,
UserRound
} from 'lucide-react';
import { AdminBadge } from '@/components/id/admin-badge';
import { useAuth } from '@/components/id/auth-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
function maskPhone(phone?: string | null) {
if (!phone) return null;
const digits = phone.replace(/\D/g, '');
if (digits.length < 10) return phone;
return `+${digits.slice(0, 1)} ${digits.slice(1, 4)} ***-**-${digits.slice(-2)}`;
}
const menuItems = [
{ href: '/data', label: 'Личные данные', subtitle: 'ФИО, день рождения, пол', icon: UserRound },
{ href: '/security', label: 'Телефон и безопасность', subtitle: 'PIN, пароль, устройства', icon: Smartphone },
{ href: '/documents', label: 'Документы', subtitle: 'Паспорт, права, загран', icon: FileText },
{ href: '/family', label: 'Семья', subtitle: 'Участники и доступ', icon: Heart },
{ href: '/security', label: 'Защита аккаунта', subtitle: 'Способы входа и сессии', icon: LockKeyhole }
];
export function UserMenu({ className }: { className?: string }) {
const router = useRouter();
const { user, token, logout } = useAuth();
const { avatarUrl } = useAvatarUrl(user?.id, user?.hasAvatar, token);
if (!user) return null;
const contactLine = [maskPhone(user.phone), user.username ?? user.email].filter(Boolean).join(' · ');
const initials = user.displayName
.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn('flex items-center gap-2 rounded-full border border-[#eceef4] bg-white py-1 pl-1 pr-3 shadow-sm transition hover:bg-[#fafbfd]', className)}
aria-label="Меню пользователя"
>
<Avatar className="h-9 w-9">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
<AvatarFallback className="text-xs font-semibold">{initials}</AvatarFallback>
</Avatar>
<span className="hidden max-w-[120px] truncate text-sm font-medium md:inline">{user.displayName}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(92vw,360px)] rounded-[28px] border-[#eceef4] p-0 shadow-2xl">
<div className="border-b border-[#eceef4] px-5 pb-5 pt-5 text-center">
<Avatar className="mx-auto h-20 w-20">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={user.displayName} /> : null}
<AvatarFallback className="text-lg font-semibold">{initials}</AvatarFallback>
</Avatar>
<div className="mt-3 flex items-center justify-center gap-2">
<p className="text-lg font-semibold">{user.displayName}</p>
<AdminBadge user={user} />
</div>
<p className="mt-1 text-sm text-[#667085]">{contactLine || 'Контакты не указаны'}</p>
</div>
<div className="p-2">
{menuItems.map((item) => (
<Link
key={`${item.href}-${item.label}`}
href={item.href}
className="flex items-center gap-3 rounded-[18px] px-3 py-3 transition hover:bg-[#f4f5f8]"
>
<item.icon className="h-5 w-5 shrink-0 text-[#667085]" />
<div className="min-w-0 flex-1 text-left">
<div className="font-medium">{item.label}</div>
<div className="truncate text-xs text-[#667085]">{item.subtitle}</div>
</div>
<ChevronRight className="h-4 w-4 text-[#a8adbc]" />
</Link>
))}
{user.canAccessAdmin ? (
<Link href="/admin/users" className="mt-1 flex items-center gap-3 rounded-[18px] px-3 py-3 transition hover:bg-[#f4f5f8]">
<ShieldCheck className="h-5 w-5 shrink-0 text-[#667085]" />
<div className="min-w-0 flex-1 text-left">
<div className="font-medium">Админ-панель</div>
<div className="text-xs text-[#667085]">Пользователи, OAuth и настройки</div>
</div>
<ChevronRight className="h-4 w-4 text-[#a8adbc]" />
</Link>
) : null}
<button
type="button"
onClick={() => {
logout();
router.push('/auth/login');
}}
className="mt-1 flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text-left transition hover:bg-[#f4f5f8]"
>
<LogOut className="h-5 w-5 shrink-0 text-[#667085]" />
<div>
<div className="font-medium">Выйти</div>
<div className="text-xs text-[#667085]">Завершить текущую сессию</div>
</div>
</button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bell, Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
AppNotification,
fetchNotifications,
fetchUnreadNotificationCount,
getApiErrorMessage,
markAllNotificationsRead,
markNotificationRead,
respondFamilyInvite
} from '@/lib/api';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { cn } from '@/lib/utils';
function formatTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' }).format(
new Date(value)
);
}
export function NotificationBell() {
const router = useRouter();
const { token, isPinLocked } = useAuth();
const { showToast } = useToast();
const { unreadCount, setUnreadCount, subscribe } = useRealtime();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<AppNotification[]>([]);
const [loading, setLoading] = useState(false);
const [respondingId, setRespondingId] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token || isPinLocked) return;
setLoading(true);
try {
const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]);
setItems(list.notifications ?? []);
setUnreadCount(count.count ?? 0);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления');
if (message) showToast(message);
} finally {
setLoading(false);
}
}, [isPinLocked, setUnreadCount, showToast, token]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return subscribe((event) => {
void load();
if (event.type === 'chat_message' && event.title) {
showToast(`${event.title}: ${event.message}`);
}
if (event.type === 'family_invite') {
showToast(event.message || 'Новое приглашение в семью');
}
});
}, [load, showToast, subscribe]);
async function openItem(item: AppNotification) {
if (!token) return;
await markNotificationRead(item.id, token);
setUnreadCount((count) => Math.max(0, count - 1));
setItems((current) => current.filter((entry) => entry.id !== item.id));
let payload: Record<string, unknown> = {};
try {
payload = item.payloadJson ? JSON.parse(item.payloadJson) : {};
} catch {
payload = {};
}
if (payload.groupId) {
setOpen(false);
router.push(`/family/${payload.groupId}`);
}
}
function removeInviteNotification(inviteId: string) {
setItems((current) => {
let removedUnread = 0;
const next = current.filter((item) => {
if (item.type !== 'family_invite') return true;
try {
const payload = item.payloadJson ? JSON.parse(item.payloadJson) as { inviteId?: string } : {};
if (payload.inviteId === inviteId) {
if (!item.isRead) removedUnread += 1;
return false;
}
} catch {
// keep item if payload is malformed
}
return true;
});
if (removedUnread > 0) {
setUnreadCount((count) => Math.max(0, count - removedUnread));
}
return next;
});
}
async function acceptInvite(inviteId: string) {
if (!token) return;
setRespondingId(inviteId);
try {
const invite = await respondFamilyInvite(inviteId, true, token);
removeInviteNotification(inviteId);
showToast('Вы присоединились к семье');
setOpen(false);
router.push(`/family/${invite.groupId}`);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось принять приглашение');
if (message) showToast(message);
} finally {
setRespondingId(null);
}
}
async function declineInvite(inviteId: string) {
if (!token) return;
setRespondingId(inviteId);
try {
await respondFamilyInvite(inviteId, false, token);
removeInviteNotification(inviteId);
showToast('Приглашение отклонено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось отклонить приглашение');
if (message) showToast(message);
} finally {
setRespondingId(null);
}
}
async function markAllRead() {
if (!token) return;
await markAllNotificationsRead(token);
setUnreadCount(0);
setItems([]);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="relative flex h-11 w-11 items-center justify-center rounded-2xl bg-[#f4f5f8] transition hover:bg-[#eceef4]"
aria-label="Уведомления"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#3390ec] px-1 text-[10px] font-semibold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[360px] rounded-[24px] p-0">
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
<h3 className="font-semibold">Уведомления</h3>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs" onClick={() => void markAllRead()}>
<Trash2 className="h-3.5 w-3.5" />
Очистить все
</Button>
</div>
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка...
</div>
) : items.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-[#667085]">Уведомлений пока нет</p>
) : (
items.map((item) => {
let inviteId: string | undefined;
try {
inviteId = item.payloadJson ? (JSON.parse(item.payloadJson).inviteId as string) : undefined;
} catch {
inviteId = undefined;
}
return (
<div
key={item.id}
className={cn(
'border-b border-[#eceef4] px-4 py-3 transition hover:bg-[#fafbfd]',
!item.isRead && 'bg-[#f0f8ff]/60'
)}
>
<button type="button" className="w-full text-left" onClick={() => void openItem(item)}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{item.title}</p>
<p className="mt-1 text-sm text-[#667085]">{item.message}</p>
</div>
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatTime(item.createdAt)}</span>
</div>
</button>
{item.type === 'family_invite' && inviteId ? (
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="h-8 flex-1 rounded-xl"
disabled={respondingId === inviteId}
onClick={() => void acceptInvite(inviteId!)}
>
{respondingId === inviteId ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Принять'}
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 flex-1 rounded-xl"
onClick={() => void declineInvite(inviteId!)}
>
Отклонить
</Button>
</div>
) : null}
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { WS_URL, type ChatMessage } from '@/lib/api';
export interface RealtimeEvent {
userId?: string;
type: string;
title: string;
message: string;
payload?: Record<string, unknown> & {
notificationId?: string;
roomId?: string;
message?: ChatMessage;
inviteId?: string;
groupId?: string;
};
}
type Listener = (event: RealtimeEvent) => void;
interface RealtimeContextValue {
unreadCount: number;
setUnreadCount: React.Dispatch<React.SetStateAction<number>>;
subscribe: (listener: Listener) => () => void;
connected: boolean;
}
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const { user, token, isPinLocked } = useAuth();
const [unreadCount, setUnreadCount] = React.useState(0);
const [connected, setConnected] = React.useState(false);
const listenersRef = React.useRef(new Set<Listener>());
const socketRef = React.useRef<WebSocket | null>(null);
const reconnectTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const subscribe = React.useCallback((listener: Listener) => {
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
}, []);
const emit = React.useCallback((event: RealtimeEvent) => {
listenersRef.current.forEach((listener) => listener(event));
}, []);
React.useEffect(() => {
if (!user || !token || isPinLocked) {
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
return;
}
let cancelled = false;
function connect() {
if (cancelled) return;
const url = `${WS_URL}?token=${encodeURIComponent(token!)}`;
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => setConnected(true);
socket.onclose = () => {
setConnected(false);
if (!cancelled) {
reconnectTimer.current = setTimeout(connect, 3000);
}
};
socket.onerror = () => socket.close();
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as RealtimeEvent;
emit(data);
if (data.type === 'family_invite' || (data.type === 'chat_message' && data.payload?.notificationId)) {
setUnreadCount((count) => count + 1);
}
} catch {
// ignore malformed payloads
}
};
}
connect();
return () => {
cancelled = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
};
}, [emit, isPinLocked, token, user]);
return (
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
{children}
</RealtimeContext.Provider>
);
}
export function useRealtime() {
const context = React.useContext(RealtimeContext);
if (!context) {
throw new Error('useRealtime должен использоваться внутри RealtimeProvider');
}
return context;
}

View File

@@ -0,0 +1,16 @@
'use client';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
export function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return <AvatarPrimitive.Root className={cn('relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full', className)} {...props} />;
}
export function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return <AvatarPrimitive.Image className={cn('aspect-square h-full w-full object-cover', className)} {...props} />;
}
export function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return <AvatarPrimitive.Fallback className={cn('flex h-full w-full items-center justify-center rounded-full bg-[#f4f5f8]', className)} {...props} />;
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-2xl text-sm font-semibold transition disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-[#20212b] text-white hover:bg-[#11131d]',
secondary: 'bg-[#f4f5f8] text-[#111827] hover:bg-[#eceef4]',
outline: 'border border-[#d8dbe5] bg-transparent hover:bg-[#f4f5f8]',
ghost: 'hover:bg-[#f4f5f8]',
white: 'bg-white text-[#171821] hover:bg-[#f3f4f7]'
},
size: {
default: 'h-11 px-5',
sm: 'h-9 px-3',
lg: 'h-14 px-8',
icon: 'h-11 w-11'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-[24px] border border-[#eceef4] bg-white text-[#111827] shadow-sm', className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-xl font-semibold tracking-tight', className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-sm text-[#667085]', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}

View File

@@ -0,0 +1,34 @@
'use client';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
export function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return <CommandPrimitive className={cn('flex h-full w-full flex-col overflow-hidden rounded-xl bg-white text-[#111827]', className)} {...props} />;
}
export function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex h-11 items-center gap-2 border-b border-[#eceef4] px-3">
<Search className="h-4 w-4 shrink-0 text-[#8a8f9e]" />
<CommandPrimitive.Input className={cn('h-10 w-full bg-transparent text-sm outline-none placeholder:text-[#8a8f9e]', className)} {...props} />
</div>
);
}
export function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return <CommandPrimitive.List className={cn('max-h-64 overflow-y-auto overflow-x-hidden p-1', className)} {...props} />;
}
export function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty className={cn('py-6 text-center text-sm text-[#667085]', className)} {...props} />;
}
export function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return <CommandPrimitive.Group className={cn('overflow-hidden p-1 text-sm', className)} {...props} />;
}
export function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return <CommandPrimitive.Item className={cn('flex cursor-pointer select-none items-center gap-3 rounded-xl px-3 py-2 text-sm outline-none data-[selected=true]:bg-[#f4f5f8]', className)} {...props} />;
}

View File

@@ -0,0 +1,30 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/50 backdrop-blur-[1px]" />
<DialogPrimitive.Content className={cn('fixed left-1/2 top-1/2 z-[201] w-[min(92vw,520px)] -translate-x-1/2 -translate-y-1/2 rounded-[28px] bg-white p-6 shadow-2xl', className)} {...props}>
{children}
<DialogPrimitive.Close className="absolute right-5 top-5 rounded-full p-1 hover:bg-[#f4f5f8]" aria-label="Закрыть">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-5 flex flex-col gap-1', className)} {...props} />;
}
export function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title className={cn('text-xl font-semibold', className)} {...props} />;
}

View File

@@ -0,0 +1,15 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/lib/utils';
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export function DropdownMenuContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return <DropdownMenuPrimitive.Content className={cn('z-50 min-w-48 rounded-2xl border border-[#eceef4] bg-white p-2 shadow-xl', className)} sideOffset={8} {...props} />;
}
export function DropdownMenuItem({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item>) {
return <DropdownMenuPrimitive.Item className={cn('flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm outline-none hover:bg-[#f4f5f8]', className)} {...props} />;
}

View File

@@ -0,0 +1,20 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
export function Form({ className, ...props }: React.FormHTMLAttributes<HTMLFormElement>) {
return <form className={cn('space-y-4', className)} {...props} />;
}
export function FormItem({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('space-y-2', className)} {...props} />;
}
export function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return <LabelPrimitive.Root className={cn('text-sm font-medium', className)} {...props} />;
}
export function FormMessage({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-sm text-red-600', className)} {...props} />;
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Input({ className, type, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
'h-12 w-full rounded-2xl border border-[#d8dbe5] bg-white px-4 text-sm outline-none transition placeholder:text-[#8a8f9e] focus:border-[#aeb3c2] focus:ring-4 focus:ring-[#eef0f6]',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useRef } from 'react';
import { cn } from '@/lib/utils';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
length?: number;
disabled?: boolean;
className?: string;
}
export function OtpInput({ value, onChange, onComplete, length = 6, disabled = false, className }: OtpInputProps) {
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const digits = Array.from({ length }, (_, index) => value[index] ?? '');
function commit(next: string) {
const clean = next.replace(/\D/g, '').slice(0, length);
onChange(clean);
if (clean.length === length) {
onComplete?.(clean);
}
}
function handleChange(index: number, raw: string) {
const char = raw.replace(/\D/g, '').slice(-1);
if (!char) return;
const chars = value.split('');
chars[index] = char;
const next = chars.join('').slice(0, length);
commit(next);
if (index < length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handleKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Backspace') {
event.preventDefault();
const chars = value.split('');
if (chars[index]) {
chars[index] = '';
onChange(chars.join(''));
} else if (index > 0) {
chars[index - 1] = '';
onChange(chars.join(''));
inputsRef.current[index - 1]?.focus();
}
}
if (event.key === 'ArrowLeft' && index > 0) {
inputsRef.current[index - 1]?.focus();
}
if (event.key === 'ArrowRight' && index < length - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
if (!pasted) return;
commit(pasted);
const focusIndex = Math.min(pasted.length, length - 1);
inputsRef.current[focusIndex]?.focus();
}
return (
<div className={cn('flex justify-between gap-2', className)}>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
inputsRef.current[index] = element;
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
disabled={disabled}
value={digit}
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"
/>
))}
</div>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { ChevronDown, X } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export interface CountryOption {
id: string;
code: string;
label: string;
maxDigits: number;
/** Path to the country flag asset inside the public directory. */
flagSrc: string;
}
export const FLAG_WIDTH = 24;
export const FLAG_HEIGHT = 18;
export const phoneCountries: CountryOption[] = [
{ id: 'RU', code: '+7', label: 'Россия', maxDigits: 10, flagSrc: '/flags/ru.svg' },
{ id: 'BY', code: '+375', label: 'Беларусь', maxDigits: 9, flagSrc: '/flags/by.svg' },
{ id: 'KZ', code: '+7', label: 'Казахстан', maxDigits: 10, flagSrc: '/flags/kz.svg' }
];
function FlagIcon({ src, className }: { src: string; className?: string }) {
return (
<span
aria-hidden="true"
className={cn('inline-block shrink-0 overflow-hidden rounded-[3px] bg-center bg-no-repeat shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]', className)}
style={{
width: `${FLAG_WIDTH}px`,
height: `${FLAG_HEIGHT}px`,
backgroundImage: `url('${src}')`,
backgroundSize: 'cover'
}}
/>
);
}
export function formatPhoneNumber(digits: string, country: CountryOption) {
const clean = digits.replace(/\D/g, '').slice(0, country.maxDigits);
if (country.code === '+375') {
const part1 = clean.slice(0, 2);
const part2 = clean.slice(2, 5);
const part3 = clean.slice(5, 7);
const part4 = clean.slice(7, 9);
if (clean.length <= 2) return part1 ? `(${part1}` : '';
if (clean.length <= 5) return `(${part1}) ${part2}`;
if (clean.length <= 7) return `(${part1}) ${part2}-${part3}`;
return `(${part1}) ${part2}-${part3}-${part4}`;
}
const part1 = clean.slice(0, 3);
const part2 = clean.slice(3, 6);
const part3 = clean.slice(6, 8);
const part4 = clean.slice(8, 10);
if (clean.length <= 3) return part1 ? `(${part1}` : '';
if (clean.length <= 6) return `(${part1}) ${part2}`;
if (clean.length <= 8) return `(${part1}) ${part2}-${part3}`;
return `(${part1}) ${part2}-${part3}-${part4}`;
}
export function toE164(country: CountryOption, digits: string) {
return `${country.code}${digits.replace(/\D/g, '').slice(0, country.maxDigits)}`;
}
export function PhoneInput({
country,
value,
onCountryChange,
onValueChange,
className
}: {
country: CountryOption;
value: string;
onCountryChange: (country: CountryOption) => void;
onValueChange: (digits: string) => void;
className?: string;
}) {
const maskedValue = formatPhoneNumber(value, country);
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',
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="Выбрать страну">
<FlagIcon src={country.flagSrc} />
<span className="text-base font-semibold">{country.code}</span>
<ChevronDown className="h-4 w-4 text-[#8f92a0]" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-1">
<Command>
<CommandInput placeholder="Страна или код" />
<CommandList>
<CommandEmpty>Страна не найдена</CommandEmpty>
<CommandGroup>
{phoneCountries.map((item) => (
<CommandItem
key={item.id}
value={`${item.label} ${item.code}`}
onSelect={() => {
onCountryChange(item);
onValueChange(value.replace(/\D/g, '').slice(0, item.maxDigits));
}}
>
<FlagIcon src={item.flagSrc} />
<span className="flex-1">{item.label}</span>
<span className="font-semibold text-[#667085]">{item.code}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<input
className="h-full min-w-0 flex-1 bg-transparent px-1 text-lg text-white outline-none placeholder:text-[#8f92a0]"
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
/>
{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="Очистить телефон">
<X className="h-4 w-4" />
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export function PopoverContent({ className, align = 'start', sideOffset = 8, ...props }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn('z-50 w-72 rounded-2xl border border-[#eceef4] bg-white p-2 text-[#111827] shadow-xl outline-none', className)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn('w-full caption-bottom text-sm', className)} {...props} />;
}
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn('[&_tr]:border-b', className)} {...props} />;
}
export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
}
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('border-b border-[#eceef4] transition-colors hover:bg-[#f8f9fb]', className)} {...props} />;
}
export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return <th className={cn('h-11 px-4 text-left align-middle font-medium text-[#667085]', className)} {...props} />;
}
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('p-4 align-middle', className)} {...props} />;
}

View File

@@ -0,0 +1,23 @@
'use client';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return <TabsPrimitive.List className={cn('inline-flex rounded-2xl bg-[#090a0f] p-0.5', className)} {...props} />;
}
export function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn('rounded-[15px] px-9 py-3 text-sm font-semibold text-[#9a9dab] data-[state=active]:bg-[#22232c] data-[state=active]:text-white', className)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn('mt-4', className)} {...props} />;
}

View File

@@ -0,0 +1,17 @@
'use client';
import * as ToastPrimitive from '@radix-ui/react-toast';
import { cn } from '@/lib/utils';
export const ToastProvider = ToastPrimitive.Provider;
export const ToastViewport = (props: React.ComponentProps<typeof ToastPrimitive.Viewport>) => (
<ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex max-w-sm flex-col gap-2" {...props} />
);
export function Toast({ className, ...props }: React.ComponentProps<typeof ToastPrimitive.Root>) {
return <ToastPrimitive.Root className={cn('rounded-2xl bg-[#20212b] p-4 text-sm text-white shadow-xl', className)} {...props} />;
}
export function ToastTitle({ className, ...props }: React.ComponentProps<typeof ToastPrimitive.Title>) {
return <ToastPrimitive.Title className={cn('font-semibold', className)} {...props} />;
}