first commit
This commit is contained in:
299
apps/frontend/components/addresses/address-form-dialog.tsx
Normal file
299
apps/frontend/components/addresses/address-form-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/frontend/components/addresses/address-map-picker.tsx
Normal file
102
apps/frontend/components/addresses/address-map-picker.tsx
Normal 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: '© <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>
|
||||
);
|
||||
}
|
||||
154
apps/frontend/components/addresses/address-quick-section.tsx
Normal file
154
apps/frontend/components/addresses/address-quick-section.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
502
apps/frontend/components/documents/document-form-dialog.tsx
Normal file
502
apps/frontend/components/documents/document-form-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/frontend/components/documents/document-photo-gallery.tsx
Normal file
105
apps/frontend/components/documents/document-photo-gallery.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
apps/frontend/components/documents/document-photo-viewer.tsx
Normal file
109
apps/frontend/components/documents/document-photo-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/frontend/components/documents/user-documents-dialog.tsx
Normal file
112
apps/frontend/components/documents/user-documents-dialog.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
805
apps/frontend/components/family/family-group-view.tsx
Normal file
805
apps/frontend/components/family/family-group-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/frontend/components/id/action-tile.tsx
Normal file
42
apps/frontend/components/id/action-tile.tsx
Normal 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>;
|
||||
}
|
||||
24
apps/frontend/components/id/admin-badge.tsx
Normal file
24
apps/frontend/components/id/admin-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/frontend/components/id/admin-nav.tsx
Normal file
41
apps/frontend/components/id/admin-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/frontend/components/id/admin-shell.tsx
Normal file
47
apps/frontend/components/id/admin-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
473
apps/frontend/components/id/auth-provider.tsx
Normal file
473
apps/frontend/components/id/auth-provider.tsx
Normal 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;
|
||||
}
|
||||
127
apps/frontend/components/id/avatar-upload.tsx
Normal file
127
apps/frontend/components/id/avatar-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/frontend/components/id/brand-logo.tsx
Normal file
43
apps/frontend/components/id/brand-logo.tsx
Normal 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>;
|
||||
}
|
||||
62
apps/frontend/components/id/pin-lock-modal.tsx
Normal file
62
apps/frontend/components/id/pin-lock-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/components/id/project-head.tsx
Normal file
18
apps/frontend/components/id/project-head.tsx
Normal 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;
|
||||
}
|
||||
64
apps/frontend/components/id/public-settings-provider.tsx
Normal file
64
apps/frontend/components/id/public-settings-provider.tsx
Normal 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);
|
||||
}
|
||||
20
apps/frontend/components/id/shell.tsx
Normal file
20
apps/frontend/components/id/shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/frontend/components/id/sidebar.tsx
Normal file
57
apps/frontend/components/id/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/frontend/components/id/toast-provider.tsx
Normal file
58
apps/frontend/components/id/toast-provider.tsx
Normal 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;
|
||||
}
|
||||
125
apps/frontend/components/id/user-menu.tsx
Normal file
125
apps/frontend/components/id/user-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
apps/frontend/components/notifications/notification-bell.tsx
Normal file
233
apps/frontend/components/notifications/notification-bell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/frontend/components/notifications/realtime-provider.tsx
Normal file
110
apps/frontend/components/notifications/realtime-provider.tsx
Normal 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;
|
||||
}
|
||||
16
apps/frontend/components/ui/avatar.tsx
Normal file
16
apps/frontend/components/ui/avatar.tsx
Normal 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} />;
|
||||
}
|
||||
38
apps/frontend/components/ui/button.tsx
Normal file
38
apps/frontend/components/ui/button.tsx
Normal 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} />;
|
||||
}
|
||||
22
apps/frontend/components/ui/card.tsx
Normal file
22
apps/frontend/components/ui/card.tsx
Normal 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} />;
|
||||
}
|
||||
34
apps/frontend/components/ui/command.tsx
Normal file
34
apps/frontend/components/ui/command.tsx
Normal 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} />;
|
||||
}
|
||||
30
apps/frontend/components/ui/dialog.tsx
Normal file
30
apps/frontend/components/ui/dialog.tsx
Normal 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} />;
|
||||
}
|
||||
15
apps/frontend/components/ui/dropdown-menu.tsx
Normal file
15
apps/frontend/components/ui/dropdown-menu.tsx
Normal 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} />;
|
||||
}
|
||||
20
apps/frontend/components/ui/form.tsx
Normal file
20
apps/frontend/components/ui/form.tsx
Normal 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} />;
|
||||
}
|
||||
15
apps/frontend/components/ui/input.tsx
Normal file
15
apps/frontend/components/ui/input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
apps/frontend/components/ui/otp-input.tsx
Normal file
91
apps/frontend/components/ui/otp-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/frontend/components/ui/phone-input.tsx
Normal file
140
apps/frontend/components/ui/phone-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/frontend/components/ui/popover.tsx
Normal file
20
apps/frontend/components/ui/popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/frontend/components/ui/table.tsx
Normal file
26
apps/frontend/components/ui/table.tsx
Normal 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} />;
|
||||
}
|
||||
23
apps/frontend/components/ui/tabs.tsx
Normal file
23
apps/frontend/components/ui/tabs.tsx
Normal 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} />;
|
||||
}
|
||||
17
apps/frontend/components/ui/toast.tsx
Normal file
17
apps/frontend/components/ui/toast.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user