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);