"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { SubscribeWithHandle, AtSubscribeSuccess } from "./HandleSubscribe";
import { EmailInput, EmailConfirm } from "./EmailSubscribe";
import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess";
import { LinkIdentityModal } from "./LinkIdentityModal";
import { Modal } from "components/Modal";
import { ButtonPrimary } from "components/Buttons";
import { ManageSubscription } from "./ManageSubscribe";
import { useToaster } from "components/Toast";
import { useIdentityData } from "components/IdentityProvider";
import { AtmosphereAccount } from "components/Icons/AtmosphereAccount";
import { EmailTiny } from "components/Icons/EmailTiny";
import { Menu, RadioMenuGroup, RadioMenuItem } from "components/Menu";
import {
requestPublicationEmailSubscription,
confirmPublicationEmailSubscription,
} from "actions/publications/subscribeEmail";
import { useViewerSubscription } from "./viewerSubscription";
import { Separator } from "components/Layout";
import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
type SubscribeMode = "email" | "atproto";
export type SubscribeProps = {
autoFocus?: boolean;
publicationUri: string;
publicationUrl?: string;
publicationName: string;
publicationDescription?: string;
newsletterMode: boolean;
};
export const SubscribePanel = (props: SubscribeProps) => {
return (
{props.publicationName}
{props.publicationDescription && (
{props.publicationDescription}
)}
);
};
export const SubscribeInput = (props: SubscribeProps) => {
let toaster = useToaster();
let router = useRouter();
let pathname = usePathname();
let searchParams = useSearchParams();
const user = useViewerSubscription(props.publicationUri);
const { identity, mutate: mutateIdentity } = useIdentityData();
let [email, setEmail] = useState(user.email ?? "");
let [confirmState, setConfirmState] = useState<"confirm" | "success">(
"confirm",
);
let [confirmOpen, setConfirmOpen] = useState(false);
let [requesting, setRequesting] = useState(false);
let [confirming, setConfirming] = useState(false);
let [locallySubscribed, setLocallySubscribed] = useState(false);
let [linkModalOpen, setLinkModalOpen] = useState(false);
let [subscribeMode, setSubscribeMode] = useState("email");
let [atSuccessOpen, setAtSuccessOpen] = useState(false);
// Tracks that the user passed through LinkIdentityModal — when they enter
// the confirmation code we attach the email to their current atp identity
// (or merge from any existing email-only identity) instead of creating a
// disconnected email-only account.
let [linkToCurrent, setLinkToCurrent] = useState(false);
const viewerHandle = identity?.bsky_profiles?.handle;
const viewerAtpDid = identity?.atp_did;
const viewerEmail = identity?.email;
// The atp-only-but-subscribing-via-email case: signed in as a Bluesky
// account with no email yet. The modal asks them to link the typed email
// (or log out) before we send a confirmation code.
const needsLinkConfirmation = !!viewerAtpDid && !viewerEmail && !!email;
// Embedded subscribe forms (see /api/subscribe_email) redirect back here
// with `subscribe_email=` after sending the confirmation code. Open
// the confirm modal so the user can paste the code from their inbox without
// re-entering their email. `subscribe_email_confirmed=1` means the user was
// already verified server-side and the modal jumps straight to success.
useEffect(() => {
if (!props.newsletterMode) return;
const incomingEmail = searchParams.get("subscribe_email");
const alreadyConfirmed =
searchParams.get("subscribe_email_confirmed") === "1";
const errorCode = searchParams.get("subscribe_email_error");
if (!incomingEmail && !errorCode) return;
if (errorCode) {
const message =
ERROR_MESSAGES[errorCode as SubscribeError] ??
"We couldn't process that subscription. Try again.";
toaster({ type: "error", content: message });
} else if (incomingEmail) {
setEmail(incomingEmail);
setConfirmState(alreadyConfirmed ? "success" : "confirm");
setConfirmOpen(true);
if (alreadyConfirmed) {
setLocallySubscribed(true);
router.refresh();
}
}
const next = new URLSearchParams(searchParams.toString());
next.delete("subscribe_email");
next.delete("subscribe_email_confirmed");
next.delete("subscribe_email_error");
const qs = next.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.newsletterMode]);
const sendRequest = async (link: boolean) => {
setRequesting(true);
setLinkToCurrent(link);
let res = await requestPublicationEmailSubscription(
props.publicationUri,
email,
);
setRequesting(false);
if (!res.ok) {
toaster({ type: "error", content: ERROR_MESSAGES[res.error] });
return;
}
if (res.value.confirmed) {
setConfirmState("success");
router.refresh();
}
setConfirmOpen(true);
};
const showManage = props.newsletterMode
? user.emailSubscribed
: user.atprotoSubscribed;
const isSubscribed = showManage || locallySubscribed;
const modeMenu = (
);
return (
<>
{isSubscribed ? (
<>
{props.newsletterMode &&
user.atprotoSubscribed &&
!user.emailSubscribed ? (
Opt in to get updates via email!
{
if (requesting) return;
if (needsLinkConfirmation) {
setLinkModalOpen(true);
return;
}
await sendRequest(false);
}}
>
Get Emails
}
/>
) : null}
>
) : props.newsletterMode ? (
{subscribeMode === "email" ? (
{
if (!email || requesting) return;
if (needsLinkConfirmation) {
setLinkModalOpen(true);
return;
}
await sendRequest(false);
}}
>
Subscribe
}
/>
) : (
setAtSuccessOpen(true)}
leading={modeMenu}
/>
)}
) : (
setLocallySubscribed(true)}
/>
)}
{props.newsletterMode && needsLinkConfirmation && (
{
setLinkModalOpen(false);
await sendRequest(true);
}}
/>
)}
{props.newsletterMode && (
<>
{
setAtSuccessOpen(open);
if (!open) {
setLocallySubscribed(true);
mutateIdentity();
router.refresh();
}
}}
>
{
setConfirmOpen(open);
if (!open) {
if (confirmState === "success") setLocallySubscribed(true);
setConfirmState("confirm");
setLinkToCurrent(false);
}
}}
>
{confirmState === "success" ? (
) : (
setConfirmOpen(false)}
emailValue={email}
onSubmit={async (code) => {
if (confirming) return;
setConfirming(true);
let res = await confirmPublicationEmailSubscription(
props.publicationUri,
email,
code,
linkToCurrent,
);
setConfirming(false);
if (!res.ok) {
toaster({
type: "error",
content: ERROR_MESSAGES[res.error],
});
return;
}
setConfirmState("success");
router.refresh();
}}
/>
)}
>
)}
>
);
};
const SubscribeModeMenu = (props: {
mode: SubscribeMode;
onChange: (mode: SubscribeMode) => void;
}) => {
return (
);
};
type SubscribeError =
| "invalid_email"
| "newsletter_disabled"
| "email_send_failed"
| "subscriber_not_found"
| "invalid_code"
| "database_error"
| "suppressed_spam_complaint"
| "suppression_delete_failed"
| "link_invalid_state"
| "email_belongs_to_other_account";
const ERROR_MESSAGES: Record = {
invalid_email: "Please enter a valid email address.",
newsletter_disabled: "This publication isn't accepting email subscriptions.",
email_send_failed: "We couldn't send the confirmation email. Try again.",
subscriber_not_found: "No pending subscription. Start over.",
invalid_code: "That code didn't match. Try again.",
database_error: "Something went wrong. Try again.",
suppressed_spam_complaint:
"This address was previously marked as spam and can't be resubscribed. Contact the publication to resolve.",
suppression_delete_failed:
"We couldn't clear a prior delivery issue on this address. Try again later.",
link_invalid_state:
"Couldn't link this email to your account. Try logging out and subscribing again.",
email_belongs_to_other_account:
"This email is already linked to a different Bluesky account. Log out to use that account instead.",
};