feat: replace back button with invite member functionality
This commit is contained in:
@@ -24,6 +24,7 @@ services:
|
|||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||||
GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL}
|
GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL}
|
||||||
|
FRONTEND_URL: ${FRONTEND_URL:-https://family-budget.duckdns.org}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import type {
|
|||||||
BulkOperationResponse,
|
BulkOperationResponse,
|
||||||
User,
|
User,
|
||||||
OAuthUrlResponse,
|
OAuthUrlResponse,
|
||||||
|
CreateInviteLinkRequest,
|
||||||
|
InviteLinkResponse,
|
||||||
|
ValidateInviteResponse,
|
||||||
|
JoinFamilyResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
@@ -133,3 +137,20 @@ export const shoppingItemApi = {
|
|||||||
clearAll: (familyId: number) =>
|
clearAll: (familyId: number) =>
|
||||||
apiClient.delete<BulkOperationResponse>(`/families/${familyId}/shopping-items/clear-all`),
|
apiClient.delete<BulkOperationResponse>(`/families/${familyId}/shopping-items/clear-all`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const inviteLinkApi = {
|
||||||
|
create: (data: CreateInviteLinkRequest) =>
|
||||||
|
apiClient.post<InviteLinkResponse>('/my-family/invite-links', data),
|
||||||
|
|
||||||
|
getMyLinks: () =>
|
||||||
|
apiClient.get<InviteLinkResponse[]>('/my-family/invite-links'),
|
||||||
|
|
||||||
|
delete: (token: string) =>
|
||||||
|
apiClient.delete(`/my-family/invite-links/${token}`),
|
||||||
|
|
||||||
|
validate: (token: string) =>
|
||||||
|
apiClient.get<ValidateInviteResponse>(`/invite/${token}`),
|
||||||
|
|
||||||
|
join: (token: string) =>
|
||||||
|
apiClient.post<JoinFamilyResponse>(`/invite/${token}/join`),
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { useStore } from '../store/useStore';
|
||||||
import type { Category, Expense } from '../types';
|
import type { Category, Expense, InviteLinkResponse } from '../types';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -18,6 +17,9 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
UserPlus,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ShoppingListModal from '../components/ShoppingListModal';
|
import ShoppingListModal from '../components/ShoppingListModal';
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ export default function FamilyView() {
|
|||||||
const [showHistory, setShowHistory] = useState<number | null>(null);
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||||
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
|
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
|
||||||
const [showShoppingList, setShowShoppingList] = useState(false);
|
const [showShoppingList, setShowShoppingList] = useState(false);
|
||||||
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||||
|
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!familyId) {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center gradient-bg">
|
<div className="min-h-screen flex items-center justify-center gradient-bg">
|
||||||
@@ -230,11 +266,11 @@ export default function FamilyView() {
|
|||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/')}
|
onClick={handleOpenInviteModal}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300 group"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||||
<span className="font-medium">Назад к списку семей</span>
|
<span className="font-medium">Пригласить участника</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
||||||
@@ -527,6 +563,84 @@ export default function FamilyView() {
|
|||||||
onClose={() => setShowShoppingList(false)}
|
onClose={() => setShowShoppingList(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showInviteModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md p-6 sm:p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-purple-500 to-blue-500 rounded-2xl">
|
||||||
|
<UserPlus className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">Пригласить участника</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInviteModal(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!inviteLink ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Создайте ссылку-приглашение, чтобы добавить нового участника в семью.
|
||||||
|
Ссылка будет действительна 7 дней.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateInviteLink}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{inviteLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Создание...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="w-5 h-5" />
|
||||||
|
Создать ссылку
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-4 text-center">
|
||||||
|
Отправьте эту ссылку участнику, которого хотите пригласить:
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-100 rounded-2xl p-4 mb-4">
|
||||||
|
<p className="text-sm text-gray-800 break-all font-mono">
|
||||||
|
{inviteLink.invite_url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyInviteLink}
|
||||||
|
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-2xl transition-all font-semibold ${
|
||||||
|
copied
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-gradient-to-r from-purple-600 to-blue-600 text-white hover:shadow-xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
Скопировано!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-5 h-5" />
|
||||||
|
Скопировать ссылку
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,3 +103,30 @@ export interface MarkAsPurchasedRequest {
|
|||||||
export interface BulkOperationResponse {
|
export interface BulkOperationResponse {
|
||||||
affected_rows: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user