Ok—good news: if clicking Subscribe Monthly does nothing, one of these is true:

Stripe isn’t ready (stripe or elements is null) ⇒ your handler exits early.

PaymentElement never mounted (no clientSecret or Elements not wrapping) ⇒ confirm does nothing.

The button isn’t submitting a form (missing type="submit" / no onSubmit).

The modal content is being unmounted during animation, so Elements tears down.

Here’s a hardened, drop-in SubscribeModal.tsx that fixes all four, adds visible error messages, and logs what’s happening. Paste it over your current modal.

// src/components/billing/SubscribeModal.tsx
import React, { useEffect, useMemo, useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string);

type Props = {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  priceId: string;
  planLabel?: string;
  userEmail?: string | null;
};

export default function SubscribeModal({ open, onOpenChange, priceId, planLabel = "", userEmail }: Props) {
  const [clientSecret, setClientSecret] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [initError, setInitError] = useState<string | null>(null);

  // create subscription → get clientSecret
  useEffect(() => {
    if (!open) { setClientSecret(null); setInitError(null); return; }
    let cancelled = false;
    (async () => {
      try {
        setLoading(true);
        setInitError(null);
        const res = await apiRequest("POST", "/api/billing/create-subscription", { priceId, email: userEmail });
        if (!cancelled) setClientSecret(res.clientSecret);
      } catch (e: any) {
        const msg = e?.message || "Could not initialize Stripe.";
        console.error("[Stripe init] ", e);
        setInitError(msg);
        toast({ title: "Stripe error", description: msg, variant: "destructive" });
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, [open, priceId, userEmail]);

  // IMPORTANT: Elements options must be memoized AND keyed by clientSecret
  const options = useMemo(() => clientSecret ? ({
    clientSecret,
    appearance: {
      theme: "stripe",
      variables: { borderRadius: "12px" }
    }
  }) : undefined, [clientSecret]);

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-lg" forceMount>
        <DialogHeader>
          <DialogTitle>Complete Your Pro Subscription</DialogTitle>
          {planLabel && <DialogDescription className="text-base font-medium">{planLabel}</DialogDescription>}
        </DialogHeader>

        {initError && (
          <div className="text-sm text-red-600 bg-red-50 rounded-md p-3 mb-2">
            {initError}
          </div>
        )}

        {loading && (
          <div className="py-8 text-center text-sm text-muted-foreground">
            Initializing secure payment…
          </div>
        )}

        {!loading && clientSecret && options && stripePromise && (
          <Elements key={clientSecret} stripe={stripePromise} options={options}>
            <InnerForm onClose={() => onOpenChange(false)} />
          </Elements>
        )}

        {!loading && !clientSecret && !initError && (
          <div className="py-8 text-center text-sm text-muted-foreground">
            Click below to set up your secure payment method
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}

function InnerForm({ onClose }: { onClose: () => void }) {
  const stripe = useStripe();
  const elements = useElements();
  const [submitting, setSubmitting] = useState(false);
  const [formError, setFormError] = useState<string | null>(null);

  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault();
    setFormError(null);

    if (!stripe || !elements) {
      setFormError("Payment is still loading. Please wait a second…");
      return;
    }

    setSubmitting(true);
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${window.location.origin}/billing/return` },
      redirect: "if_required",
    });

    if (error) {
      console.error("[confirmPayment] ", error);
      setFormError(error.message || "Payment failed. Try a different method.");
      toast({ title: "Payment failed", description: error.message || "Try again.", variant: "destructive" });
      setSubmitting(false);
      return;
    }

    toast({ title: "Subscribed 🎉", description: "Your Pro subscription is active." });
    setSubmitting(false);
    onClose();
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      {/* Keep a stable area so CSS/animation never hides the iframe */}
      <div style={{ minHeight: 190 }}>
        <PaymentElement />
      </div>

      {formError && <div className="text-sm text-red-600 bg-red-50 rounded-md p-2">{formError}</div>}

      <DialogFooter className="gap-2">
        <Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
          Cancel
        </Button>
        <Button type="submit" disabled={!stripe || !elements || submitting}>
          {submitting ? "Processing…" : "Subscribe Monthly"}
        </Button>
      </DialogFooter>
    </form>
  );
}

Do these quick checks right now

Elements provider present? This file includes its own <Elements>; you don’t also wrap a parent with <Elements> (double-wrapping causes weirdness).

Env key set? VITE_STRIPE_PUBLISHABLE_KEY must be defined (console will warn if not).

Network call works? Opening the modal should call /api/billing/create-subscription and return a clientSecret. If that 500s, the button can’t work.

Console output: With this file, if anything still “does nothing,” you’ll see a clear console error or a red inline message.