diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index 985799a..c050acb 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -18,6 +18,7 @@ from app.schemas import ( MarathonUpdate, MarathonResponse, MarathonListItem, + MarathonPublicInfo, JoinMarathon, ParticipantInfo, ParticipantWithUser, @@ -30,6 +31,35 @@ from app.schemas import ( router = APIRouter(prefix="/marathons", tags=["marathons"]) +# Public endpoint (no auth required) +@router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo) +async def get_marathon_by_code(invite_code: str, db: DbSession): + """Get public marathon info by invite code. No authentication required.""" + result = await db.execute( + select(Marathon, func.count(Participant.id).label("participants_count")) + .outerjoin(Participant) + .options(selectinload(Marathon.creator)) + .where(Marathon.invite_code == invite_code) + .group_by(Marathon.id) + ) + row = result.first() + + if not row: + raise HTTPException(status_code=404, detail="Marathon not found") + + marathon = row[0] + participants_count = row[1] + + return MarathonPublicInfo( + id=marathon.id, + title=marathon.title, + description=marathon.description, + status=marathon.status, + participants_count=participants_count, + creator_nickname=marathon.creator.nickname, + ) + + def generate_invite_code() -> str: return secrets.token_urlsafe(8) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index a8acd8e..9b29139 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -12,6 +12,7 @@ from app.schemas.marathon import ( MarathonUpdate, MarathonResponse, MarathonListItem, + MarathonPublicInfo, ParticipantInfo, ParticipantWithUser, JoinMarathon, @@ -65,6 +66,7 @@ __all__ = [ "MarathonUpdate", "MarathonResponse", "MarathonListItem", + "MarathonPublicInfo", "ParticipantInfo", "ParticipantWithUser", "JoinMarathon", diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py index a81797e..2f69fc4 100644 --- a/backend/app/schemas/marathon.py +++ b/backend/app/schemas/marathon.py @@ -79,6 +79,19 @@ class JoinMarathon(BaseModel): invite_code: str +class MarathonPublicInfo(BaseModel): + """Public info about marathon for invite page (no auth required)""" + id: int + title: str + description: str | None + status: str + participants_count: int + creator_nickname: str + + class Config: + from_attributes = True + + class LeaderboardEntry(BaseModel): rank: int user: UserPublic diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6265bd..2988e0b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { MarathonPage } from '@/pages/MarathonPage' import { LobbyPage } from '@/pages/LobbyPage' import { PlayPage } from '@/pages/PlayPage' import { LeaderboardPage } from '@/pages/LeaderboardPage' +import { InvitePage } from '@/pages/InvitePage' // Protected route wrapper function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -43,6 +44,9 @@ function App() { }> } /> + {/* Public invite page */} + } /> + => { + // Public endpoint - no auth required + const response = await client.get(`/marathons/by-code/${inviteCode}`) + return response.data + }, + create: async (data: CreateMarathonData): Promise => { const response = await client.post('/marathons', data) return response.data diff --git a/frontend/src/pages/InvitePage.tsx b/frontend/src/pages/InvitePage.tsx new file mode 100644 index 0000000..9a24a12 --- /dev/null +++ b/frontend/src/pages/InvitePage.tsx @@ -0,0 +1,168 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { marathonsApi } from '@/api' +import type { MarathonPublicInfo } from '@/types' +import { useAuthStore } from '@/store/auth' +import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui' +import { Users, Loader2, Trophy, UserPlus, LogIn } from 'lucide-react' + +export function InvitePage() { + const { code } = useParams<{ code: string }>() + const navigate = useNavigate() + const { isAuthenticated, setPendingInviteCode } = useAuthStore() + + const [marathon, setMarathon] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isJoining, setIsJoining] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + loadMarathon() + }, [code]) + + const loadMarathon = async () => { + if (!code) return + + try { + const data = await marathonsApi.getByCode(code) + setMarathon(data) + } catch { + setError('Марафон не найден или ссылка недействительна') + } finally { + setIsLoading(false) + } + } + + const handleJoin = async () => { + if (!code) return + + setIsJoining(true) + try { + const joined = await marathonsApi.join(code) + navigate(`/marathons/${joined.id}`) + } catch (err: unknown) { + const apiError = err as { response?: { data?: { detail?: string } } } + const detail = apiError.response?.data?.detail + if (detail === 'Already joined this marathon') { + // Already a member, just redirect + navigate(`/marathons/${marathon?.id}`) + } else { + setError(detail || 'Не удалось присоединиться') + } + } finally { + setIsJoining(false) + } + } + + const handleAuthRedirect = (path: string) => { + if (code) { + setPendingInviteCode(code) + } + navigate(path) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error || !marathon) { + return ( +
+ + +
{error || 'Марафон не найден'}
+ + + +
+
+
+ ) + } + + const statusText = { + preparing: 'Подготовка', + active: 'Активен', + finished: 'Завершён', + }[marathon.status] + + return ( +
+ + + + + Приглашение в марафон + + + + {/* Marathon info */} +
+

{marathon.title}

+ {marathon.description && ( +

{marathon.description}

+ )} +
+ + + {marathon.participants_count} участников + + + {statusText} + +
+

+ Организатор: {marathon.creator_nickname} +

+
+ + {marathon.status === 'finished' ? ( +
+ Этот марафон уже завершён +
+ ) : isAuthenticated ? ( + /* Authenticated - show join button */ + + ) : ( + /* Not authenticated - show login/register options */ +
+

+ Чтобы присоединиться, войдите или зарегистрируйтесь +

+ + +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 7cc1ed3..c2f0ee5 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useAuthStore } from '@/store/auth' +import { marathonsApi } from '@/api' import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' const loginSchema = z.object({ @@ -15,7 +16,7 @@ type LoginForm = z.infer export function LoginPage() { const navigate = useNavigate() - const { login, isLoading, error, clearError } = useAuthStore() + const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() const [submitError, setSubmitError] = useState(null) const { @@ -31,6 +32,19 @@ export function LoginPage() { clearError() try { await login(data) + + // Check for pending invite code + const pendingCode = consumePendingInviteCode() + if (pendingCode) { + try { + const marathon = await marathonsApi.join(pendingCode) + navigate(`/marathons/${marathon.id}`) + return + } catch { + // If join fails (already member, etc), just go to marathons + } + } + navigate('/marathons') } catch { setSubmitError(error || 'Ошибка входа') diff --git a/frontend/src/pages/MarathonPage.tsx b/frontend/src/pages/MarathonPage.tsx index 3e5f883..5b400ce 100644 --- a/frontend/src/pages/MarathonPage.tsx +++ b/frontend/src/pages/MarathonPage.tsx @@ -34,9 +34,14 @@ export function MarathonPage() { } } - const copyInviteCode = () => { + const getInviteLink = () => { + if (!marathon) return '' + return `${window.location.origin}/invite/${marathon.invite_code}` + } + + const copyInviteLink = () => { if (marathon) { - navigator.clipboard.writeText(marathon.invite_code) + navigator.clipboard.writeText(getInviteLink()) setCopied(true) setTimeout(() => setCopied(false), 2000) } @@ -229,16 +234,16 @@ export function MarathonPage() { - {/* Invite code */} + {/* Invite link */} {marathon.status !== 'finished' && ( -

Код приглашения

+

Ссылка для приглашения

- - {marathon.invite_code} + + {getInviteLink()} -

- Поделитесь этим кодом с друзьями, чтобы они могли присоединиться к марафону + Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону

diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index c733ba6..e8d13bd 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useAuthStore } from '@/store/auth' +import { marathonsApi } from '@/api' import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' const registerSchema = z.object({ @@ -27,7 +28,7 @@ type RegisterForm = z.infer export function RegisterPage() { const navigate = useNavigate() - const { register: registerUser, isLoading, error, clearError } = useAuthStore() + const { register: registerUser, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() const [submitError, setSubmitError] = useState(null) const { @@ -47,6 +48,19 @@ export function RegisterPage() { password: data.password, nickname: data.nickname, }) + + // Check for pending invite code + const pendingCode = consumePendingInviteCode() + if (pendingCode) { + try { + const marathon = await marathonsApi.join(pendingCode) + navigate(`/marathons/${marathon.id}`) + return + } catch { + // If join fails, just go to marathons + } + } + navigate('/marathons') } catch { setSubmitError(error || 'Ошибка регистрации') diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 6a6b317..46aa0b0 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -9,21 +9,25 @@ interface AuthState { isAuthenticated: boolean isLoading: boolean error: string | null + pendingInviteCode: string | null login: (data: LoginData) => Promise register: (data: RegisterData) => Promise logout: () => void clearError: () => void + setPendingInviteCode: (code: string | null) => void + consumePendingInviteCode: () => string | null } export const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ user: null, token: null, isAuthenticated: false, isLoading: false, error: null, + pendingInviteCode: null, login: async (data) => { set({ isLoading: true, error: null }) @@ -77,6 +81,14 @@ export const useAuthStore = create()( }, clearError: () => set({ error: null }), + + setPendingInviteCode: (code) => set({ pendingInviteCode: code }), + + consumePendingInviteCode: () => { + const code = get().pendingInviteCode + set({ pendingInviteCode: null }) + return code + }, }), { name: 'auth-storage', @@ -84,6 +96,7 @@ export const useAuthStore = create()( user: state.user, token: state.token, isAuthenticated: state.isAuthenticated, + pendingInviteCode: state.pendingInviteCode, }), } ) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a27ab17..ccfc140 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -70,6 +70,15 @@ export interface MarathonCreate { game_proposal_mode: GameProposalMode } +export interface MarathonPublicInfo { + id: number + title: string + description: string | null + status: MarathonStatus + participants_count: number + creator_nickname: string +} + export interface LeaderboardEntry { rank: number user: User