feat: add project invitation and join pages
Add MyInvitationsPage, ProjectInvitationPage, and ProjectJoinPage for handling project invitation flows.
This commit is contained in:
parent
b737d19166
commit
4322f36a76
250
src/app/me/MyInvitationsPage.tsx
Normal file
250
src/app/me/MyInvitationsPage.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Mail, X } from "lucide-react";
|
||||
import {
|
||||
projectAcceptInvitation,
|
||||
projectCancelJoinRequest,
|
||||
projectMyInvitations,
|
||||
projectMyJoinRequests,
|
||||
projectRejectInvitation,
|
||||
} from "@/client/api";
|
||||
import type { InvitationResponse, JoinRequestResponse } from "@/client/model";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
type ActionKey = `invite:${string}` | `request:${number}` | null;
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "Unknown";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === "approved") return "default";
|
||||
if (status === "rejected") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function MyInvitationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actionKey, setActionKey] = useState<ActionKey>(null);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const invitationsQuery = useQuery({
|
||||
queryKey: ["project-my-invitations"],
|
||||
queryFn: async () => {
|
||||
const res = await projectMyInvitations({ page: 1, per_page: 50 });
|
||||
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["project-my-join-requests"],
|
||||
queryFn: async () => {
|
||||
const res = await projectMyJoinRequests({ page: 1, per_page: 50 });
|
||||
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["project-my-invitations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["project-my-join-requests"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
};
|
||||
|
||||
const handleInvitation = async (projectName: string, accept: boolean) => {
|
||||
setActionKey(`invite:${projectName}`);
|
||||
setMessage(null);
|
||||
try {
|
||||
if (accept) {
|
||||
await projectAcceptInvitation(projectName);
|
||||
} else {
|
||||
await projectRejectInvitation(projectName);
|
||||
}
|
||||
setMessage({ type: "success", text: accept ? "Invitation accepted." : "Invitation rejected." });
|
||||
invalidate();
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to process invitation." });
|
||||
} finally {
|
||||
setActionKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRequest = async (request: JoinRequestResponse) => {
|
||||
setActionKey(`request:${request.id}`);
|
||||
setMessage(null);
|
||||
try {
|
||||
await projectCancelJoinRequest(request.username, request.id);
|
||||
setMessage({ type: "success", text: "Join request cancelled." });
|
||||
invalidate();
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to cancel join request." });
|
||||
} finally {
|
||||
setActionKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const invitations = invitationsQuery.data ?? [];
|
||||
const requests = requestsQuery.data ?? [];
|
||||
const isLoading = invitationsQuery.isLoading || requestsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background p-8">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Invitations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review project invitations and track your join requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<Alert variant={message.type === "error" ? "destructive" : "default"}>
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pending Invitations</CardTitle>
|
||||
<CardDescription>Invitations sent by project admins.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : invitations.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Mail />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No pending invitations</EmptyTitle>
|
||||
<EmptyDescription>New project invitations will appear here.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
invitations.map((invitation) => {
|
||||
const loading = actionKey === `invite:${invitation.project_name}`;
|
||||
return (
|
||||
<Card key={invitation.project_uid} size="sm" className="bg-muted/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link to={`/${invitation.project_name}`} className="hover:underline">
|
||||
{invitation.project_name}
|
||||
</Link>
|
||||
<Badge variant="secondary">{invitation.scope}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Invited by {invitation.invited_by_username ?? "Unknown"} on{" "}
|
||||
{formatDate(invitation.created_at)}
|
||||
</CardDescription>
|
||||
<CardAction className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleInvitation(invitation.project_name, true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleInvitation(invitation.project_name, false)}
|
||||
disabled={loading}
|
||||
>
|
||||
<X data-icon="inline-start" />
|
||||
Reject
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Join Requests</CardTitle>
|
||||
<CardDescription>Your project membership applications.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No join requests</EmptyTitle>
|
||||
<EmptyDescription>Projects you apply to join will be tracked here.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
requests.map((request) => {
|
||||
const loading = actionKey === `request:${request.id}`;
|
||||
return (
|
||||
<Card key={request.id} size="sm" className="bg-muted/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link to={`/${request.username}`} className="hover:underline">
|
||||
{request.username}
|
||||
</Link>
|
||||
<Badge variant={statusVariant(request.status)}>{request.status}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Submitted on {formatDate(request.created_at)}
|
||||
{request.reject_reason ? ` · ${request.reject_reason}` : ""}
|
||||
</CardDescription>
|
||||
{request.status === "pending" && (
|
||||
<CardAction>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelRequest(request)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
|
||||
Cancel
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyInvitationsPage;
|
||||
151
src/app/project/ProjectInvitationPage.tsx
Normal file
151
src/app/project/ProjectInvitationPage.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Mail, X } from "lucide-react";
|
||||
import {
|
||||
projectAcceptInvitation,
|
||||
projectMyInvitations,
|
||||
projectRejectInvitation,
|
||||
} from "@/client/api";
|
||||
import type { InvitationResponse } from "@/client/model";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "Unknown";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function ProjectInvitationPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const { data: invitations = [], isLoading } = useQuery({
|
||||
queryKey: ["project-my-invitations"],
|
||||
queryFn: async () => {
|
||||
const res = await projectMyInvitations({ page: 1, per_page: 50 });
|
||||
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invitation = useMemo(
|
||||
() => invitations.find((item) => item.project_name === projectName),
|
||||
[invitations, projectName],
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["project-my-invitations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
};
|
||||
|
||||
const handleDecision = async (accept: boolean) => {
|
||||
if (!projectName) return;
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
if (accept) {
|
||||
await projectAcceptInvitation(projectName);
|
||||
} else {
|
||||
await projectRejectInvitation(projectName);
|
||||
}
|
||||
invalidate();
|
||||
if (accept) {
|
||||
navigate(`/${projectName}`, { replace: true });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Invitation rejected." });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to process invitation." });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-xl">
|
||||
{message && (
|
||||
<Alert variant={message.type === "error" ? "destructive" : "default"} className="mb-4">
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Invitation</CardTitle>
|
||||
<CardDescription>Accept or reject your invitation to join this project.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : !invitation ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Mail />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No pending invitation</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
This invitation may have already been processed. You can review all invitations from your profile.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/me/invitations">View invitations</Link>
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Card size="sm" className="bg-muted/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{invitation.project_name}
|
||||
<Badge variant="secondary">{invitation.scope}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Invited by {invitation.invited_by_username ?? "Unknown"} on {formatDate(invitation.created_at)}
|
||||
</CardDescription>
|
||||
<CardAction className="flex gap-2">
|
||||
<Button onClick={() => handleDecision(true)} disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
|
||||
Accept
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleDecision(false)} disabled={isSubmitting}>
|
||||
<X data-icon="inline-start" />
|
||||
Reject
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectInvitationPage;
|
||||
260
src/app/project/ProjectJoinPage.tsx
Normal file
260
src/app/project/ProjectJoinPage.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Send, X } from "lucide-react";
|
||||
import {
|
||||
projectCancelJoinRequest,
|
||||
projectInfo,
|
||||
projectJoinSettings,
|
||||
projectMyJoinRequests,
|
||||
projectSubmitJoinRequest,
|
||||
} from "@/client/api";
|
||||
import type {
|
||||
AnswerRequest,
|
||||
JoinRequestResponse,
|
||||
JoinSettingsResponse,
|
||||
ProjectInfoRelational,
|
||||
QuestionSchema,
|
||||
} from "@/client/model";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function parseQuestions(settings?: JoinSettingsResponse | null): QuestionSchema[] {
|
||||
if (!settings?.require_questions) return [];
|
||||
if (Array.isArray(settings.questions)) {
|
||||
return settings.questions
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return { question: item };
|
||||
if (item && typeof item === "object" && "question" in item) {
|
||||
return { question: String((item as { question: unknown }).question ?? "") };
|
||||
}
|
||||
return { question: "" };
|
||||
})
|
||||
.filter((item) => item.question.trim().length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "Unknown";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === "approved") return "default";
|
||||
if (status === "rejected") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function ProjectJoinPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [message, setMessage] = useState("");
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const projectQuery = useQuery({
|
||||
queryKey: ["project-info", projectName],
|
||||
queryFn: async (): Promise<ProjectInfoRelational | null> => {
|
||||
if (!projectName) return null;
|
||||
const res = await projectInfo(projectName);
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
enabled: !!projectName,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ["project-join-settings", projectName],
|
||||
queryFn: async () => {
|
||||
const res = await projectJoinSettings(projectName!);
|
||||
return res.data?.data as JoinSettingsResponse;
|
||||
},
|
||||
enabled: !!projectName,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["project-my-join-requests"],
|
||||
queryFn: async () => {
|
||||
const res = await projectMyJoinRequests({ page: 1, per_page: 50 });
|
||||
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const questions = useMemo(() => parseQuestions(settingsQuery.data), [settingsQuery.data]);
|
||||
const existingRequest = useMemo(
|
||||
() => requestsQuery.data?.find((request) => request.username === projectName),
|
||||
[projectName, requestsQuery.data],
|
||||
);
|
||||
|
||||
const isMember = !!projectQuery.data?.role;
|
||||
const isLoading = settingsQuery.isLoading || requestsQuery.isLoading;
|
||||
const canJoinWithoutReason = !settingsQuery.data?.require_approval && questions.length === 0;
|
||||
const missingRequiredAnswer = questions.some((item) => !answers[item.question]?.trim());
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["project-my-join-requests"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["project-info", projectName] });
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!projectName || missingRequiredAnswer) return;
|
||||
setIsSubmitting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const payloadAnswers: AnswerRequest[] = questions.map((item) => ({
|
||||
question: item.question,
|
||||
answer: answers[item.question]?.trim() ?? "",
|
||||
}));
|
||||
await projectSubmitJoinRequest(projectName, {
|
||||
message: message.trim() || null,
|
||||
answers: payloadAnswers,
|
||||
});
|
||||
setMessage("");
|
||||
setAnswers({});
|
||||
setFeedback({ type: "success", text: "Join request submitted." });
|
||||
invalidate();
|
||||
} catch {
|
||||
setFeedback({ type: "error", text: "Failed to submit join request." });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!projectName || !existingRequest) return;
|
||||
setIsSubmitting(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
await projectCancelJoinRequest(projectName, existingRequest.id);
|
||||
setFeedback({ type: "success", text: "Join request cancelled." });
|
||||
invalidate();
|
||||
} catch {
|
||||
setFeedback({ type: "error", text: "Failed to cancel join request." });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
{feedback && (
|
||||
<Alert variant={feedback.type === "error" ? "destructive" : "default"} className="mb-4">
|
||||
<AlertDescription>{feedback.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Join {projectQuery.data?.display_name || projectName}</CardTitle>
|
||||
<CardDescription>
|
||||
{projectQuery.data?.description || "Submit a request to become a project member."}
|
||||
</CardDescription>
|
||||
{projectQuery.data && (
|
||||
<CardAction>
|
||||
<Badge variant={projectQuery.data.is_public ? "secondary" : "outline"}>
|
||||
{projectQuery.data.is_public ? "Public" : "Private"}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : isMember ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert>
|
||||
<AlertDescription>You are already a member of this project.</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link to={`/${projectName}`}>Open project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : existingRequest && existingRequest.status !== "cancelled" ? (
|
||||
<Card size="sm" className="bg-muted/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Current request
|
||||
<Badge variant={statusVariant(existingRequest.status)}>{existingRequest.status}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Submitted on {formatDate(existingRequest.created_at)}
|
||||
{existingRequest.reject_reason ? ` · ${existingRequest.reject_reason}` : ""}
|
||||
</CardDescription>
|
||||
{existingRequest.status === "pending" && (
|
||||
<CardAction>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
|
||||
Cancel request
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<FieldGroup>
|
||||
{!canJoinWithoutReason && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="join-message">Message</FieldLabel>
|
||||
<Textarea
|
||||
id="join-message"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="Tell the admins why you want to join."
|
||||
rows={4}
|
||||
/>
|
||||
<FieldDescription>Optional, but useful for private or approval-required projects.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{questions.map((item, index) => (
|
||||
<Field key={`${item.question}-${index}`} data-invalid={!answers[item.question]?.trim()}>
|
||||
<FieldLabel htmlFor={`join-answer-${index}`}>{item.question}</FieldLabel>
|
||||
<Input
|
||||
id={`join-answer-${index}`}
|
||||
value={answers[item.question] ?? ""}
|
||||
onChange={(event) =>
|
||||
setAnswers((current) => ({ ...current, [item.question]: event.target.value }))
|
||||
}
|
||||
aria-invalid={!answers[item.question]?.trim()}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || missingRequiredAnswer}>
|
||||
{isSubmitting ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Send data-icon="inline-start" />}
|
||||
{canJoinWithoutReason ? "Join project" : "Submit join request"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectJoinPage;
|
||||
Loading…
Reference in New Issue
Block a user