import { useQuery } from "@tanstack/react-query";
import { useLocation, useRouter } from "@tanstack/react-router";
import { getAccountNotices } from "~/fns/notices";
import type { AccountNotice, QuotaNotice } from "~/fns/notices";
import { useEffect, useState } from "react";
import { useEmailUnverified, useViewer } from "~/hooks/use-viewer";
import { useAction } from "~/hooks/use-action";
import { resendVerificationEmail } from "~/fns/account-security";
import { VERIFY_EMAIL_PATH } from "~/lib/email-verification";
import { NagBanner } from "~/ui/NagBanner";
import { safeDecode } from "~/lib/safe-decode";
const NOTICE_STALE_MS = 5 * 60 * 1000;
export const ACCOUNT_NOTICES_KEY = "account-notices";
function QuotaBanner({ notice }: { notice: QuotaNotice }) {
const whose = notice.accountKind === "org" ? notice.accountName : "You";
const title = notice.spend
? `${whose} reached ${notice.accountKind === "org" ? "its" : "your"} monthly spending limit`
: `${whose} used all of ${notice.accountKind === "org" ? "its" : "your"} ${notice.plan} ${notice.meterLabel.toLowerCase()} this month`;
const body = notice.spend
? "Usage that costs money is paused until next month or until the limit is raised."
: notice.canUpgrade
? `Upgrade to Pro for more, or buy prepaid credit${notice.rate ? ` (${notice.rate})` : ""}.`
: notice.rate
? `More is ${notice.rate}; add credit or raise the spending limit.`
: "It resets at the start of next month.";
return (
{body}
);
}
function Notice({ notice }: { notice: AccountNotice }) {
if (notice.kind === "quota") return ;
if (notice.kind === "tokens") {
return (
Git and the API authenticate with a token, not your password; you have none that is still valid.
);
}
if (notice.kind === "disabled") {
return (
Its repositories are read-only and its hosting is stopped. {notice.message}
);
}
return (
An authenticator app or a passkey keeps your repositories safe even if your password leaks.
);
}
/** Persistent until the email is verified: an unverified account can read but not write or pay (AUTH-B1). */
function VerifyEmailBanner({ email }: { email: string }) {
const resend = useAction(resendVerificationEmail, { invalidate: false });
const [sent, setSent] = useState(false);
const router = useRouter();
// The link is usually opened in another tab; re-read the session when this one regains focus so the banner clears.
useEffect(() => {
const refresh = () => void router.invalidate();
window.addEventListener("focus", refresh);
return () => window.removeEventListener("focus", refresh);
}, [router]);
return (
setSent(Boolean(await resend.run({ data: { email } }))),
}
}
snoozeDays={0}
>
{resend.error ??
(sent
? `Sent a new link to ${email}.`
: `Until you open the link we sent to ${email}, you can browse, star and follow but not create, comment, push or pay.`)}
);
}
/** Account-level nags under the site header: unverified email, maxed-out plans and missing two-factor. */
export function AccountNotices() {
const viewer = useViewer();
const unverified = useEmailUnverified();
const location = useLocation();
const notices = useQuery({
queryKey: [ACCOUNT_NOTICES_KEY, viewer?.id ?? null, unverified],
queryFn: () => getAccountNotices(),
enabled: Boolean(viewer),
staleTime: NOTICE_STALE_MS,
});
const banner = viewer && unverified ? : null;
if (!viewer || !notices.data?.length) return banner;
const onSecurity = location.pathname.startsWith("/settings/security");
const onTokens = location.pathname.startsWith("/settings/tokens");
const path = safeDecode(location.pathname) ?? location.pathname;
return (
{banner}
{notices.data
.filter(
(notice) =>
!(notice.kind === "mfa" && onSecurity) &&
!(notice.kind === "tokens" && onTokens) &&
!(notice.kind === "quota" && (safeDecode(notice.billingPath) ?? notice.billingPath) === path),
)
.map((notice) => (
))}
);
}