"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { SubscribeWithHandle, AtSubscribeSuccess } from "./HandleSubscribe";
import { EmailInput, EmailButton, EmailConfirm } from "./EmailSubscribe";
import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess";
import { LinkIdentityModal } from "./LinkIdentityModal";
import { SUBSCRIBE_ERROR_MESSAGES as ERROR_MESSAGES } from "./subscribeErrors";
import { Modal } from "components/Modal";
import { ButtonPrimary } from "components/Buttons";
import { ManageSubscription } from "./ManageSubscribe";
import { useToaster } from "components/Toast";
import {
refreshIdentityData,
useIdentityData,
} from "components/IdentityProvider";
import { AtmosphereAccount } from "components/Icons/AtmosphereAccount";
import { EmailTiny } from "components/Icons/EmailTiny";
import { Menu, MenuItem, RadioMenuGroup, RadioMenuItem } from "components/Menu";
import { RSSTiny } from "components/Icons/RSSTiny";
import {
requestPublicationEmailSubscription,
confirmPublicationEmailSubscription,
} from "actions/publications/subscribeEmail";
import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions";
import { mainSiteAuthBase } from "src/utils/customDomain";
import type { SubscriptionSource } from "src/subscriptionSource";
import { useViewerSubscription } from "./viewerSubscription";
import { useSubscribeSuccessData } from "./useSubscribeSuccessData";
import { Separator } from "components/Layout";
import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
import { PaidSubscribeButton } from "./PaidSubscribeButton";
import { useMembershipTiers } from "components/Memberships/useMembershipTiers";
type SubscribeMode = "email" | "atproto";
// Logged-out email subscribe goes through the main-site email-login flow with a
// `subscribe` after-sign-in action, so the session is minted on the main site
// and handed back to the custom domain (see postAuthRedirect).
function redirectToEmailSubscribe(
email: string,
publicationUri: string,
source?: SubscriptionSource,
) {
let base = mainSiteAuthBase() || window.location.origin;
let url = new URL("/api/auth/email-login", base);
url.searchParams.set("email", email);
url.searchParams.set("redirect", window.location.href);
url.searchParams.set(
"action",
encodeActionToSearchParam({
action: "subscribe",
publication: publicationUri,
// The subscribe completes after a redirect, so stamp the originating
// page into the source now — the server won't see a useful Referer.
...(source ? { source: { url: window.location.href, ...source } } : {}),
}),
);
window.location.href = url.toString();
}
export type SubscribeProps = {
autoFocus?: boolean;
compact?: boolean;
publicationUri: string;
publicationUrl?: string;
publicationName: string;
publicationDescription?: string;
newsletterMode: boolean;
// Analytics: where on the page this subscribe control sits.
source?: SubscriptionSource;
};
export const SubscribePanel = (props: SubscribeProps) => {
return (
{props.publicationName}
{props.publicationDescription && (
{props.publicationDescription}
)}
);
};
export const SubscribeInput = (props: SubscribeProps) => {
let toaster = useToaster();
let router = useRouter();
const user = useViewerSubscription(props.publicationUri);
const { identity, mutate: mutateIdentity } = useIdentityData();
let [email, setEmail] = useState(user.email ?? "");
// On a published page identity resolves after first paint, so the initial
// value above is always the logged-out one. Without this the email field
// stays empty while EmailInput disables itself on the identity's email being
// known — an input that can be neither typed into nor submitted. Only seeds
// an untouched field, so it can't clobber what someone is typing.
useEffect(() => {
if (user.email)
setEmail((current) => (current === "" ? user.email! : current));
}, [user.email]);
let [requesting, setRequesting] = useState(false);
let [confirming, setConfirming] = useState(false);
let [confirmOpen, setConfirmOpen] = useState(false);
let [confirmState, setConfirmState] = useState<"confirm" | "success">(
"confirm",
);
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);
let [locallySubscribed, setLocallySubscribed] = useState(false);
let [linkModalOpen, setLinkModalOpen] = useState(false);
let [subscribeMode, setSubscribeMode] = useState("email");
const membershipTiers = useMembershipTiers(props.publicationUri);
// Warm the success-modal data (pub name + recommended listings) while the
// form is idle, so subscribing opens the modal without a loading spinner.
useSubscribeSuccessData(props.publicationUri);
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;
const sendRequest = async (link: boolean) => {
setRequesting(true);
setLinkToCurrent(link);
let res = await requestPublicationEmailSubscription(
props.publicationUri,
email,
props.source,
);
setRequesting(false);
if (!res.ok) {
toaster({ type: "error", content: ERROR_MESSAGES[res.error] });
return;
}
if (res.value.confirmed) {
setConfirmState("success");
refreshIdentityData();
router.refresh();
}
setConfirmOpen(true);
};
const isSubscribed = user.subscribed || locallySubscribed;
const modeMenu = (
);
// Paid memberships replace the subscribe form with the paid join flow.
if (membershipTiers.hasPaidTiers && membershipTiers.tiers)
return ;
const emailForm = (
{
if (!email || requesting) return;
if (needsLinkConfirmation) {
setLinkModalOpen(true);
return;
}
redirectToEmailSubscribe(email, props.publicationUri, props.source);
}}
action={
Subscribe
}
/>
);
return (
<>
{isSubscribed ? (
<>
{props.newsletterMode &&
user.atprotoSubscribed &&
!user.emailEnabled ? (
Opt in to get updates via email!
{
if (requesting || !email) return;
if (needsLinkConfirmation) {
setLinkModalOpen(true);
return;
}
await sendRequest(false);
}}
action={
Get Emails
}
/>
) : null}
>
) : props.newsletterMode ? (
{user.loggedIn && user.email ? (
setLocallySubscribed(true)}
onSuccess={(mode) => {
if (mode === "email") {
setConfirmState("success");
setConfirmOpen(true);
} else setAtSuccessOpen(true);
}}
/>
) : subscribeMode === "email" ? (
emailForm
) : (
setAtSuccessOpen(true)}
leading={modeMenu}
/>
)}
) : (
setLocallySubscribed(true)}
onAtSuccess={() => setAtSuccessOpen(true)}
/>
)}
{props.newsletterMode && needsLinkConfirmation && (
{
setLinkModalOpen(false);
await sendRequest(true);
}}
/>
)}
{
setAtSuccessOpen(open);
if (!open) {
setLocallySubscribed(true);
mutateIdentity();
router.refresh();
}
}}
>
{props.newsletterMode && (
{
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,
props.source,
);
setConfirming(false);
if (!res.ok) {
toaster({
type: "error",
content: ERROR_MESSAGES[res.error],
});
return;
}
setConfirmState("success");
refreshIdentityData();
router.refresh();
}}
/>
)}
)}
>
);
};
export const SubscribeButton = (props: SubscribeProps) => {
const user = useViewerSubscription(props.publicationUri);
let [locallySubscribed, setLocallySubscribed] = useState(false);
let [atSuccessOpen, setAtSuccessOpen] = useState(false);
let [emailSuccessOpen, setEmailSuccessOpen] = useState(false);
const membershipTiers = useMembershipTiers(props.publicationUri);
// Paid memberships replace the one-click subscribe with the paid join flow.
if (membershipTiers.hasPaidTiers && membershipTiers.tiers)
return (
);
const subscribeTrigger = (
Subscribe
);
return (
<>
{user.subscribed || locallySubscribed ? (
) : !props.newsletterMode && user.loggedIn && user.handle ? (
setLocallySubscribed(true)}
onAtSuccess={() => setAtSuccessOpen(true)}
/>
) : props.newsletterMode && user.loggedIn && user.email ? (
setLocallySubscribed(true)}
onSuccess={(mode) => {
if (mode === "email") setEmailSuccessOpen(true);
else setAtSuccessOpen(true);
}}
/>
) : (
// Nothing to one-click with — either logged out, or logged in but
// missing the identity this pub needs (a handle for atproto pubs, an
// email for newsletters). Both open the full SubscribePanel (pub
// name/description + form) in a modal.
)}
{
if (!open) setAtSuccessOpen(false);
}}
>
{
if (!open) setEmailSuccessOpen(false);
}}
>
>
);
};
export const SubscribeInputModeMenu = (props: {
mode: SubscribeMode;
onChange: (mode: SubscribeMode) => void;
}) => {
return (
);
};
export type SubscribeButtonModeMenuAccount = {
value: SubscribeMode;
label: string;
icon: React.ReactNode;
// The account the main button currently subscribes with — marked in the menu.
selected: boolean;
// Subscribe with this account directly, rather than just toggling selection.
onSelect: () => void;
};
// The caret dropdown beside the compact subscribe button. Each account is a
// one-click subscribe (it runs the subscribe action, not just a toggle), and
// the optional RSS item links the feed. Shared by the atproto (HandleSubscribe)
// and email (EmailSubscribe) buttons.
export const SubscribeButtonModeMenu = (props: {
disabled: boolean;
publicationUrl?: string;
accounts: SubscribeButtonModeMenuAccount[];
}) => {
let selectedValue =
props.accounts.find((a) => a.selected)?.value ?? props.accounts[0]?.value;
return (
);
};