From 31bafb3c76b449f9f3f764c53abb9ee00ff24398 Mon Sep 17 00:00:00 2001 From: arrelin Date: Sat, 17 Jan 2026 12:38:34 +0300 Subject: [PATCH] feat: replace back button with invite member functionality --- docker-compose.prod.yml | 1 + frontend/src/api/client.ts | 21 +++++ frontend/src/pages/FamilyView.tsx | 126 ++++++++++++++++++++++++++++-- frontend/src/types/index.ts | 27 +++++++ 4 files changed, 169 insertions(+), 6 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index baec5bc..8b66779 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -24,6 +24,7 @@ services: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL} + FRONTEND_URL: ${FRONTEND_URL:-https://family-budget.duckdns.org} depends_on: - postgres networks: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4e1e005..d7fb56a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -20,6 +20,10 @@ import type { BulkOperationResponse, User, OAuthUrlResponse, + CreateInviteLinkRequest, + InviteLinkResponse, + ValidateInviteResponse, + JoinFamilyResponse, } from '../types'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; @@ -133,3 +137,20 @@ export const shoppingItemApi = { clearAll: (familyId: number) => apiClient.delete(`/families/${familyId}/shopping-items/clear-all`), }; + +export const inviteLinkApi = { + create: (data: CreateInviteLinkRequest) => + apiClient.post('/my-family/invite-links', data), + + getMyLinks: () => + apiClient.get('/my-family/invite-links'), + + delete: (token: string) => + apiClient.delete(`/my-family/invite-links/${token}`), + + validate: (token: string) => + apiClient.get(`/invite/${token}`), + + join: (token: string) => + apiClient.post(`/invite/${token}/join`), +}; diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index c743385..1231fab 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -1,10 +1,9 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { categoryApi, expenseApi } from '../api/client'; +import { categoryApi, expenseApi, inviteLinkApi } from '../api/client'; import { useStore } from '../store/useStore'; -import type { Category, Expense } from '../types'; +import type { Category, Expense, InviteLinkResponse } from '../types'; import { - ArrowLeft, Wallet, TrendingDown, Plus, @@ -18,6 +17,9 @@ import { Calendar, MessageSquare, ShoppingCart, + UserPlus, + Copy, + Check, } from 'lucide-react'; import ShoppingListModal from '../components/ShoppingListModal'; @@ -42,6 +44,10 @@ export default function FamilyView() { const [showHistory, setShowHistory] = useState(null); const [categoryExpenses, setCategoryExpenses] = useState([]); const [showShoppingList, setShowShoppingList] = useState(false); + const [showInviteModal, setShowInviteModal] = useState(false); + const [inviteLink, setInviteLink] = useState(null); + const [inviteLoading, setInviteLoading] = useState(false); + const [copied, setCopied] = useState(false); useEffect(() => { if (!familyId) { @@ -180,6 +186,36 @@ export default function FamilyView() { } }; + const handleCreateInviteLink = async () => { + try { + setInviteLoading(true); + const response = await inviteLinkApi.create({ expires_in_hours: 168 }); + setInviteLink(response.data); + } catch (err) { + alert('Ошибка создания ссылки-приглашения'); + console.error(err); + } finally { + setInviteLoading(false); + } + }; + + const handleCopyInviteLink = async () => { + if (!inviteLink) return; + try { + await navigator.clipboard.writeText(inviteLink.invite_url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleOpenInviteModal = () => { + setShowInviteModal(true); + setInviteLink(null); + setCopied(false); + }; + if (loading) { return (
@@ -230,11 +266,11 @@ export default function FamilyView() {
@@ -527,6 +563,84 @@ export default function FamilyView() { onClose={() => setShowShoppingList(false)} /> )} + + {showInviteModal && ( +
+
+
+
+
+ +
+

Пригласить участника

+
+ +
+ + {!inviteLink ? ( +
+

+ Создайте ссылку-приглашение, чтобы добавить нового участника в семью. + Ссылка будет действительна 7 дней. +

+ +
+ ) : ( +
+

+ Отправьте эту ссылку участнику, которого хотите пригласить: +

+
+

+ {inviteLink.invite_url} +

+
+ +
+ )} +
+
+ )}
); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6d77f17..a2b518a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -103,3 +103,30 @@ export interface MarkAsPurchasedRequest { export interface BulkOperationResponse { affected_rows: number; } + +export interface CreateInviteLinkRequest { + expires_in_hours?: number; + max_uses?: number; +} + +export interface InviteLinkResponse { + id: number; + family_id: number; + token: string; + invite_url: string; + expires_at: string | null; + max_uses: number | null; + uses_count: number; +} + +export interface ValidateInviteResponse { + valid: boolean; + family_id: number | null; + family_name: string | null; +} + +export interface JoinFamilyResponse { + success: boolean; + family_id: number; + message: string; +}