diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx b/app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx index c9b1071b..4d5ea98b 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx +++ b/app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx @@ -260,6 +260,10 @@ export const NewsletterSettings = () => {

)} + +
+ + { ); }; +const EmbedFormSnippet = (props: { publicationUri: string }) => { + let toaster = useToaster(); + let appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; + let actionUrl = `${appUrl.replace(/\/$/, "")}/api/subscribe_email`; + let snippet = `
+ + + +
`; + + return ( +
+

Embed Subscribe Form

+

+ Paste this HTML into any webpage to let readers subscribe to your + publication. After submitting, they're redirected here to confirm with + a code we email them. +

+
+
+          {snippet}
+        
+ { + try { + await navigator.clipboard.writeText(snippet); + toaster({ type: "success", content: "Copied!" }); + } catch { + toaster({ type: "error", content: "Couldn't copy to clipboard." }); + } + }} + > + Copy + +
+
+ ); +}; + export const ManageProSubscription = (props: { compact?: boolean }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/app/api/subscribe_email/route.ts b/app/api/subscribe_email/route.ts new file mode 100644 index 00000000..4de03a3e --- /dev/null +++ b/app/api/subscribe_email/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requestPublicationEmailSubscription } from "actions/publications/subscribeEmail"; +import { supabaseServerClient } from "supabase/serverClient"; +import { + getPublicationURL, + getBasePublicationURL, +} from "app/(app)/lish/createPub/getPublicationURL"; +import { isProductionDomain } from "src/utils/isProductionDeployment"; + +export const dynamic = "force-dynamic"; + +const SUBSCRIBE_QUERY = "subscribe_email"; +const ERROR_QUERY = "subscribe_email_error"; + +export async function POST(req: NextRequest) { + const contentType = req.headers.get("content-type") || ""; + let email: string | null = null; + let publicationUri: string | null = null; + let returnTo: string | null = null; + + if (contentType.includes("application/json")) { + const body = (await req.json().catch(() => null)) as + | Record + | null; + if (body && typeof body === "object") { + email = typeof body.email === "string" ? body.email : null; + publicationUri = + typeof body.publication === "string" ? body.publication : null; + returnTo = + typeof body.return_to === "string" ? body.return_to : null; + } + } else { + const form = await req.formData(); + const e = form.get("email"); + const p = form.get("publication"); + const r = form.get("return_to"); + email = typeof e === "string" ? e : null; + publicationUri = typeof p === "string" ? p : null; + returnTo = typeof r === "string" ? r : null; + } + + if (!publicationUri) { + return new NextResponse("Missing publication", { status: 400 }); + } + const requestOrigin = new URL(req.url).origin; + if (!email) { + return redirectWithError( + publicationUri, + returnTo, + requestOrigin, + "invalid_email", + ); + } + + const result = await requestPublicationEmailSubscription( + publicationUri, + email, + ); + + if (!result.ok) { + return redirectWithError( + publicationUri, + returnTo, + requestOrigin, + result.error, + ); + } + + const target = await resolveReturnUrl(publicationUri, returnTo, requestOrigin); + if (!target) { + return new NextResponse("Publication not found", { status: 404 }); + } + target.searchParams.set(SUBSCRIBE_QUERY, email); + if (result.value.confirmed) { + target.searchParams.set("subscribe_email_confirmed", "1"); + } + return NextResponse.redirect(target.toString(), 303); +} + +async function redirectWithError( + publicationUri: string, + returnTo: string | null, + requestOrigin: string, + error: string, +) { + const target = await resolveReturnUrl(publicationUri, returnTo, requestOrigin); + if (!target) { + return new NextResponse(`Subscription failed: ${error}`, { status: 400 }); + } + target.searchParams.set(ERROR_QUERY, error); + return NextResponse.redirect(target.toString(), 303); +} + +async function resolveReturnUrl( + publicationUri: string, + returnTo: string | null, + requestOrigin: string, +): Promise { + const { data: publication } = await supabaseServerClient + .from("publications") + .select("uri, record") + .eq("uri", publicationUri) + .maybeSingle(); + if (!publication) return null; + + // In dev/preview, the publication's recorded URL points at the production + // domain (or a custom domain) — neither serves this dev server. Force the + // local `/lish/...` path so the redirect lands somewhere we can test. + const pubUrl = isProductionDomain() + ? getPublicationURL(publication) + : getBasePublicationURL(publication); + const base = + pubUrl.startsWith("http://") || pubUrl.startsWith("https://") + ? pubUrl + : `${requestOrigin}${pubUrl}`; + + if (returnTo) { + try { + const candidate = new URL(returnTo, base); + const baseOrigin = new URL(base).origin; + if (candidate.origin === baseOrigin) return candidate; + } catch {} + } + + return new URL(base); +} diff --git a/components/Subscribe/SubscribeButton.tsx b/components/Subscribe/SubscribeButton.tsx index 6712ce7e..d8c11c67 100644 --- a/components/Subscribe/SubscribeButton.tsx +++ b/components/Subscribe/SubscribeButton.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; +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"; @@ -55,6 +55,8 @@ export const SubscribePanel = (props: SubscribeProps) => { 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 ?? ""); @@ -82,6 +84,43 @@ export const SubscribeInput = (props: SubscribeProps) => { // (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);