arbret/frontend/app/invites/page.tsx

330 lines
9.5 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
interface Invite {
id: number;
identifier: string;
status: string;
used_by_email: string | null;
created_at: string;
spent_at: string | null;
}
export default function InvitesPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredRole: "regular",
fallbackRedirect: "/audit",
});
const [invites, setInvites] = useState<Invite[]>([]);
const [isLoadingInvites, setIsLoadingInvites] = useState(true);
const [copiedId, setCopiedId] = useState<number | null>(null);
const fetchInvites = useCallback(async () => {
try {
const data = await api.get<Invite[]>("/api/invites");
setInvites(data);
} catch (err) {
console.error("Failed to load invites:", err);
} finally {
setIsLoadingInvites(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchInvites();
}
}, [user, isAuthorized, fetchInvites]);
const getInviteUrl = (identifier: string) => {
if (typeof window !== "undefined") {
return `${window.location.origin}/signup/${identifier}`;
}
return `/signup/${identifier}`;
};
const copyToClipboard = async (invite: Invite) => {
const url = getInviteUrl(invite.identifier);
try {
await navigator.clipboard.writeText(url);
setCopiedId(invite.id);
setTimeout(() => setCopiedId(null), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
if (isLoading || isLoadingInvites) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
const readyInvites = invites.filter((i) => i.status === "ready");
const spentInvites = invites.filter((i) => i.status === "spent");
const revokedInvites = invites.filter((i) => i.status === "revoked");
return (
<main style={styles.main}>
<Header currentPage="invites" />
<div style={styles.content}>
<div style={styles.pageCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Invites</h1>
<p style={styles.cardSubtitle}>
Share your invite codes with friends to let them join
</p>
</div>
{invites.length === 0 ? (
<div style={styles.emptyState}>
<p style={styles.emptyText}>You don&apos;t have any invites yet.</p>
<p style={styles.emptyHint}>
Contact an admin if you need invite codes to share.
</p>
</div>
) : (
<div style={styles.sections}>
{/* Ready Invites */}
{readyInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Available ({readyInvites.length})
</h2>
<p style={styles.sectionHint}>
Share these links with people you want to invite
</p>
<div style={styles.inviteList}>
{readyInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCard}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteActions}>
<button
onClick={() => copyToClipboard(invite)}
style={styles.copyButton}
>
{copiedId === invite.id ? "Copied!" : "Copy Link"}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Spent Invites */}
{spentInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Used ({spentInvites.length})
</h2>
<div style={styles.inviteList}>
{spentInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}>
<span style={styles.statusBadgeSpent}>Used</span>
<span style={styles.inviteeEmail}>
by {invite.used_by_email}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Revoked Invites */}
{revokedInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>
Revoked ({revokedInvites.length})
</h2>
<div style={styles.inviteList}>
{revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<span style={styles.statusBadgeRevoked}>Revoked</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
pageCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
padding: "2.5rem",
width: "100%",
maxWidth: "600px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
cardHeader: {
marginBottom: "2rem",
},
cardTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "2rem",
fontWeight: 400,
color: "#fff",
margin: 0,
letterSpacing: "-0.02em",
},
cardSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
marginTop: "0.5rem",
fontSize: "0.95rem",
},
emptyState: {
textAlign: "center",
padding: "2rem 0",
},
emptyText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.6)",
fontSize: "1rem",
margin: 0,
},
emptyHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
fontSize: "0.85rem",
marginTop: "0.5rem",
},
sections: {
display: "flex",
flexDirection: "column",
gap: "2rem",
},
section: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.8)",
margin: 0,
textTransform: "uppercase",
letterSpacing: "0.05em",
},
sectionHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.4)",
margin: 0,
},
inviteList: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
inviteCard: {
background: "rgba(99, 102, 241, 0.1)",
border: "1px solid rgba(99, 102, 241, 0.3)",
borderRadius: "12px",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
inviteCardSpent: {
background: "rgba(34, 197, 94, 0.08)",
border: "1px solid rgba(34, 197, 94, 0.2)",
borderRadius: "12px",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
inviteCardRevoked: {
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: "12px",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
},
inviteCode: {
fontFamily: "'DM Mono', monospace",
fontSize: "0.95rem",
color: "#fff",
letterSpacing: "0.02em",
},
inviteActions: {
display: "flex",
gap: "0.5rem",
},
copyButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
fontWeight: 500,
padding: "0.5rem 1rem",
background: "rgba(99, 102, 241, 0.3)",
color: "#fff",
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
},
inviteeMeta: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
statusBadgeSpent: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
background: "rgba(34, 197, 94, 0.2)",
color: "rgba(34, 197, 94, 0.9)",
borderRadius: "4px",
textTransform: "uppercase",
},
statusBadgeRevoked: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
background: "rgba(239, 68, 68, 0.2)",
color: "rgba(239, 68, 68, 0.9)",
borderRadius: "4px",
textTransform: "uppercase",
},
inviteeEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.6)",
},
};
const styles = { ...sharedStyles, ...pageStyles };