"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 (
{props.mode === "email" ? : } } >
Subscribe via…
props.onChange(v as SubscribeMode)} > Email Atmosphere
); }; 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 ( } >
Subscribe with…
{props.accounts.map((account) => ( account.onSelect()} > {account.icon} {account.label} ))} {props.publicationUrl && ( window.open( `${props.publicationUrl}/rss`, "_blank", "noopener,noreferrer", ) } > RSS Feed )}
); };