Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*
* Public routes (no auth required):
* - `/sign-in/*`, `/sign-up/*` — Clerk's hosted auth pages
* - `/invite/:token` — shareable, temporary invite link landing page
*
* `RootRoute` (mounted at `/`) decides where to send a signed-in user:
* usually `DashboardPage` inside AppShell, but it can also bounce to
Expand All @@ -30,6 +31,7 @@ import { AppShell } from "./components/AppShell";
import { AccountModalProvider } from "./components/AccountModal";
import { SignInPage } from "./pages/SignInPage";
import { SignUpPage } from "./pages/SignUpPage";
import { InviteLinkPage } from "./pages/InviteLinkPage";
import { LeaguesPage } from "./pages/LeaguesPage";
import { TeamPage } from "./pages/TeamPage";
import { LeaguePage } from "./pages/LeaguePage";
Expand All @@ -54,6 +56,7 @@ export default function App() {
<Routes>
<Route path="/sign-in/*" element={<SignInPage />} />
<Route path="/sign-up/*" element={<SignUpPage />} />
<Route path="/invite/:token" element={<InviteLinkPage />} />
<Route path="/" element={<RootRoute />} />
{/* Account/settings are surfaced via the AccountModal now;
redirect any lingering deep links back to the dashboard. */}
Expand Down
61 changes: 61 additions & 0 deletions client/src/hooks/useHuddles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
DuesPayment,
DuesResponse,
CountdownConfig,
InviteLinkHuddle,
} from "../types/huddle";

function authHeader(token: string | null): Record<string, string> {
Expand Down Expand Up @@ -245,6 +246,66 @@ export function useRotateInviteCode() {
});
}

export function useGenerateInviteLink() {
const { getToken } = useAuth();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { huddleId: string }) => {
const token = await getToken();
try {
const res = await axios.post<{ huddle: Huddle }>(
`/api/huddles/${input.huddleId}/invite-link`,
{},
{ headers: authHeader(token) },
);
return res.data.huddle;
} catch (err) {
throw new Error(errorMessage(err, "Failed to generate invite link"));
}
},
onSuccess: (huddle) => {
queryClient.invalidateQueries({ queryKey: ["huddle", huddle.id] });
queryClient.invalidateQueries({ queryKey: ["huddles"] });
},
});
}

export function useRevokeInviteLink() {
const { getToken } = useAuth();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { huddleId: string }) => {
const token = await getToken();
try {
const res = await axios.delete<{ huddle: Huddle }>(
`/api/huddles/${input.huddleId}/invite-link`,
{ headers: authHeader(token) },
);
return res.data.huddle;
} catch (err) {
throw new Error(errorMessage(err, "Failed to revoke invite link"));
}
},
onSuccess: (huddle) => {
queryClient.invalidateQueries({ queryKey: ["huddle", huddle.id] });
queryClient.invalidateQueries({ queryKey: ["huddles"] });
},
});
}

/** Public lookup for the /invite/:token landing page — no auth required. */
export function useInviteLinkLookup(token: string | null) {
return useQuery({
queryKey: ["invite-link", token],
queryFn: async () => {
const res = await axios.get<{ huddle: InviteLinkHuddle }>(`/api/invite-links/${token}`);
return res.data.huddle;
},
enabled: !!token,
retry: false,
});
}

export function useLookupHuddleByCode() {
const { getToken } = useAuth();
return useMutation({
Expand Down
123 changes: 123 additions & 0 deletions client/src/pages/CommissionerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
useHuddlePendingClaims,
useDecideClaim,
useRotateInviteCode,
useGenerateInviteLink,
useRevokeInviteLink,
useAddCommissioner,
useRemoveCommissioner,
useDeleteHuddle,
Expand Down Expand Up @@ -374,6 +376,120 @@ function InviteCodePanel({
);
}

function InviteLinkPanel({
huddleId,
inviteLinkToken,
inviteLinkExpiresAt,
}: {
huddleId: string;
inviteLinkToken?: string | null;
inviteLinkExpiresAt?: string | null;
}) {
const generate = useGenerateInviteLink();
const revoke = useRevokeInviteLink();
const [copied, setCopied] = useState(false);
const [confirmingRegenerate, setConfirmingRegenerate] = useState(false);
const [confirmingRevoke, setConfirmingRevoke] = useState(false);

const token = generate.data?.inviteLinkToken ?? inviteLinkToken;
const expiresAt = generate.data?.inviteLinkExpiresAt ?? inviteLinkExpiresAt;
const hasActiveLink = !!token && !!expiresAt && new Date(expiresAt).getTime() > Date.now();
const link = token ? `${window.location.origin}/invite/${token}` : null;

const handleCopy = () => {
if (link) {
navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};

return (
<Panel>
<PanelHeader
title="Invite link"
description="A temporary, shareable link that walks new members through sign-up and straight to claiming their team. Expires automatically after 7 days."
/>
{hasActiveLink && link ? (
<>
<div className="flex items-center gap-3">
<div className="flex-1 truncate bg-highlight border border-line rounded-md py-2.5 px-3 font-mono text-[12px] text-ink">
{link}
</div>
<Btn onClick={handleCopy} className="px-4 py-2.5">
{copied ? "Copied!" : "Copy"}
</Btn>
</div>
<p className="text-[11px] text-muted font-sans">
Expires {new Date(expiresAt!).toLocaleString()}
</p>
</>
) : (
<p className="text-[12px] text-muted font-sans">No active invite link.</p>
)}

{!confirmingRegenerate && !confirmingRevoke && (
<div className="flex gap-2">
<Btn onClick={() => setConfirmingRegenerate(true)}>
{hasActiveLink ? "Regenerate…" : "Generate link"}
</Btn>
{hasActiveLink && <Btn onClick={() => setConfirmingRevoke(true)}>Revoke</Btn>}
</div>
)}

{confirmingRegenerate && (
<div className="rounded-md border border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 flex flex-col gap-2">
<p className="text-[12px] text-amber-800 dark:text-amber-300 font-sans">
{hasActiveLink
? "The old link stops working immediately. Continue?"
: "Generate a new invite link?"}
</p>
<div className="flex gap-2">
<Btn onClick={() => setConfirmingRegenerate(false)} disabled={generate.isPending}>
Cancel
</Btn>
<BtnPrimary
onClick={() =>
generate.mutate({ huddleId }, { onSuccess: () => setConfirmingRegenerate(false) })
}
disabled={generate.isPending}
>
{generate.isPending ? "Generating…" : "Yes, generate"}
</BtnPrimary>
</div>
</div>
)}

{confirmingRevoke && (
<div className="rounded-md border border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 flex flex-col gap-2">
<p className="text-[12px] text-amber-800 dark:text-amber-300 font-sans">
This link stops working immediately. Continue?
</p>
<div className="flex gap-2">
<Btn onClick={() => setConfirmingRevoke(false)} disabled={revoke.isPending}>
Cancel
</Btn>
<BtnPrimary
onClick={() =>
revoke.mutate({ huddleId }, { onSuccess: () => setConfirmingRevoke(false) })
}
disabled={revoke.isPending}
>
{revoke.isPending ? "Revoking…" : "Yes, revoke"}
</BtnPrimary>
</div>
</div>
)}

{(generate.isError || revoke.isError) && (
<p className="text-[11.5px] text-red-600 font-sans">
{((generate.error ?? revoke.error) as Error).message}
</p>
)}
</Panel>
);
}

// ─── Manage commissioners panel ───────────────────────────────────────────────

function ManageCommissionersPanel({
Expand Down Expand Up @@ -1908,6 +2024,13 @@ export function CommissionerPage() {
inviteCode={detail.huddle.inviteCode}
/>
</div>
<div className="break-inside-avoid mb-5">
<InviteLinkPanel
huddleId={huddle.id}
inviteLinkToken={detail.huddle.inviteLinkToken}
inviteLinkExpiresAt={detail.huddle.inviteLinkExpiresAt}
/>
</div>
<div className="break-inside-avoid mb-5">
<ManageCommissionersPanel
huddleId={huddle.id}
Expand Down
132 changes: 132 additions & 0 deletions client/src/pages/InviteLinkPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* InviteLinkPage — public landing page for a shareable, temporary invite
* link.
*
* Route: /invite/:token — deliberately NOT wrapped in AuthGuard/AppShell,
* since it has to work for someone who isn't signed in yet. Reuses
* LandingPage.tsx's plain nav/hero/footer chrome rather than the
* AppShell-internal Panel/Btn primitives.
*
* - Signed out: shows who they're invited to join, with sign-up/sign-in
* CTAs that carry this page's path forward via an `after` redirect param
* (see SignUpPage.tsx/SignInPage.tsx).
* - Signed in: selects the league in Redux (same action JoinHuddleModal
* uses) and hands off to the existing team-claim picker at
* /league-settings — this page never creates a claim itself.
*/
import { useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useUser } from "@clerk/clerk-react";
import { Button } from "../components/ui/button";
import { useInviteLinkLookup } from "../hooks/useHuddles";
import { useAppDispatch } from "../store/hooks";
import { setSelectedLeague } from "../store/slices/authSlice";

function Nav() {
return (
<nav className="bg-white border-b px-6 py-4 flex items-center justify-between">
<Link to="/">
<h1 className="text-xl font-bold">Huddle</h1>
</Link>
</nav>
);
}

function Footer() {
return (
<footer className="py-6 text-center text-xs text-gray-400">
&copy; {new Date().getFullYear()} Huddle
</footer>
);
}

function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<Nav />
<main className="flex-1 flex flex-col items-center justify-center px-6 text-center">
{children}
</main>
<Footer />
</div>
);
}

export function InviteLinkPage() {
const { token } = useParams<{ token: string }>();
const { isSignedIn, isLoaded } = useUser();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { data: huddle, isLoading, isError } = useInviteLinkLookup(token ?? null);

// Once signed in, select the league and hand off — dispatch and navigate
// together in one effect so the redirect can't fire before the league is
// actually selected.
useEffect(() => {
if (!isLoaded || !isSignedIn || !huddle) return;
if (huddle.leagueId) {
dispatch(setSelectedLeague(huddle.leagueId));
navigate("/league-settings", { replace: true });
} else {
navigate("/", { replace: true });
}
}, [isLoaded, isSignedIn, huddle, dispatch, navigate]);

if (!token) {
return (
<Shell>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Invite link not found</h2>
<Link to="/">
<Button size="lg">Go to Huddle</Button>
</Link>
</Shell>
);
}

if (isLoading || !isLoaded || isSignedIn) {
// isSignedIn is included here too: once true, the effect above takes
// over and redirects — this state is just a brief loading placeholder.
return (
<Shell>
<p className="text-sm text-gray-500">Loading…</p>
</Shell>
);
}

if (isError || !huddle) {
return (
<Shell>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Invite link not valid</h2>
<p className="text-gray-500 max-w-md mb-8">
This invite link is invalid or has expired. Ask your commissioner for a new one.
</p>
<Link to="/">
<Button size="lg">Go to Huddle</Button>
</Link>
</Shell>
);
}

const afterParam = `?after=${encodeURIComponent(`/invite/${token}`)}`;

return (
<Shell>
<h2 className="text-2xl font-bold text-gray-900 mb-3">
You're invited to join {huddle.name}
</h2>
<p className="text-gray-500 max-w-md mb-8">
Sign up for Huddle to claim your team in this league.
</p>
<div className="flex items-center gap-3">
<Link to={`/sign-up${afterParam}`}>
<Button size="lg">Sign up to join</Button>
</Link>
<Link to={`/sign-in${afterParam}`}>
<Button variant="outline" size="lg">
I already have an account
</Button>
</Link>
</div>
</Shell>
);
}
7 changes: 6 additions & 1 deletion client/src/pages/SignInPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { SignIn } from "@clerk/clerk-react";
import { useSearchParams } from "react-router-dom";

export function SignInPage() {
const [params] = useSearchParams();
// Carries an invite link's path through sign-in — see InviteLinkPage.tsx.
const after = params.get("after") || "/";

return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<SignIn routing="path" path="/sign-in" afterSignInUrl="/" />
<SignIn routing="path" path="/sign-in" afterSignInUrl={after} />
</div>
);
}
Loading