That modal is just a pretty shell right now: it has a title, price, and buttons, but it isn’t mounting Stripe’s Payment Element, so there’s nowhere to enter card details. The double title is because you’ve got a Dialog header and a Card header both saying “Complete Your Pro Subscription.”

Here’s a clean, drop-in fix:

Use Stripe Payment Element inside the modal (embedded checkout).

Fetch a clientSecret from your backend for the selected Price (mode=subscription).

Mount <PaymentElement /> inside <Elements />.

Remove one of the headers.

Below are two full files. Paste them in as-is.

1) src/components/billing/SubscribeModal.tsx (full file)
import React, { useEffect, useMemo, useState } from "react";
import { loadStripe, StripeElementsOptions } 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";

// ----- CONFIG -----
// Publishable key must start with "pk_"
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string);

type SubscribeModalProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  priceId: string;          // e.g. "price_123"
  planLabel?: string;       // e.g. "$19/month"
  userEmail?: string | null;
};

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

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

  const options: StripeElementsOptions | undefined = useMemo(() => clientSecret ? ({
    clientSecret,
    appearance: {
      theme: "stripe",
      variables: {
        colorPrimary: "var(--primary)",
        borderRadius: "12px",
      }
    }
  }) : undefined, [clientSecret]);

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-lg">
        {/* ONE header only (fixes your double-title issue) */}
        <DialogHeader>
          <DialogTitle>Complete Your Pro Subscription</DialogTitle>
          {planLabel && <DialogDescription className="text-base font-medium">{planLabel}</DialogDescription>}
        </DialogHeader>

        <Card className="border-none shadow-none p-0">
          <CardContent className="p-0">
            {loading && (
              <div className="py-8 text-center text-sm text-muted-foreground">Initializing secure payment…</div>
            )}

            {!loading && clientSecret && stripePromise && (
              <Elements stripe={stripePromise} options={options}>
                <PaymentForm onClose={() => onOpenChange(false)} />
              </Elements>
            )}
          </CardContent>
        </Card>
      </DialogContent>
    </Dialog>
  );
}

// -------------------- Inner Form --------------------

function PaymentForm({ onClose }: { onClose: () => void }) {
  const stripe = useStripe();
  const elements = useElements();
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setSubmitting(true);
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        // Optional: set a return URL if you want to redirect after 3DS
        return_url: `${window.location.origin}/billing/return`,
      },
      redirect: "if_required",
    });

    if (error) {
      toast({ title: "Payment failed", description: error.message || "Please try another payment method.", 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">
      {/* This renders card input + any wallet methods configured */}
      <PaymentElement />
      <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>
  );
}

2) Backend: src/server/routes/billing.ts (full file)

Adjust paths to match your API layout. This uses Stripe’s Subscriptions + Payment Element flow: create a Customer (or reuse), create a Subscription with payment_behavior: 'default_incomplete', then return the client_secret of the latest invoice’s PaymentIntent.

import type { Request, Response } from "express";
import express from "express";
import Stripe from "stripe";

// Set these in your Replit / env vars
// VITE_STRIPE_PUBLISHABLE_KEY (client)
// STRIPE_SECRET_KEY (server)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: "2024-06-20",
});

const router = express.Router();

/**
 * POST /api/billing/create-subscription
 * Body: { priceId: string, email?: string }
 * Returns: { clientSecret: string, subscriptionId: string }
 */
router.post("/create-subscription", async (req: Request, res: Response) => {
  try {
    const { priceId, email } = req.body as { priceId: string; email?: string | null };
    if (!priceId) return res.status(400).json({ error: "Missing priceId" });

    // 1) Find or create customer. Replace with your user->customer mapping.
    let customer: Stripe.Customer;
    if (email) {
      const existing = await stripe.customers.list({ email, limit: 1 });
      customer = existing.data[0] ?? await stripe.customers.create({ email });
    } else {
      customer = await stripe.customers.create();
    }

    // 2) Create the subscription in incomplete state so we can collect payment
    const subscription = await stripe.subscriptions.create({
      customer: customer.id,
      items: [{ price: priceId }],
      payment_behavior: "default_incomplete",
      expand: ["latest_invoice.payment_intent"],
    });

    const paymentIntent = (subscription.latest_invoice as Stripe.Invoice).payment_intent as Stripe.PaymentIntent;
    if (!paymentIntent?.client_secret) {
      return res.status(500).json({ error: "No client secret on payment intent." });
    }

    return res.json({
      clientSecret: paymentIntent.client_secret,
      subscriptionId: subscription.id,
    });
  } catch (err: any) {
    console.error("create-subscription failed", err);
    return res.status(500).json({ error: err.message || "Stripe error" });
  }
});

export default router;


Then wire the route in your server entry:

// src/server/index.ts
import billingRoutes from "./routes/billing";
// ...
app.use("/api/billing", billingRoutes);

Why your modal looked wrong

No Payment Element mounted → no card fields.

Two headers → you rendered a modal header and another header inside the Card. Keep only the Dialog header (as in the file above).

(Common gotcha) If you previously used Stripe Checkout (redirect) and then switched to an inline modal, you must use this Payment-Element flow with a clientSecret—the Checkout Session URL won’t render fields inside your modal.

Quick wire-up checklist

Set env vars:

VITE_STRIPE_PUBLISHABLE_KEY (client)

STRIPE_SECRET_KEY (server)

Add the route and restart.

Use the modal:

<SubscribeModal
  open={showSubscribe}
  onOpenChange={setShowSubscribe}
  priceId="price_XXXX"
  planLabel="$19/month"
  userEmail={currentUser?.email}
/>


Test with Stripe test cards (e.g., 4242 4242 4242 4242).

If you prefer Stripe’s hosted Checkout instead of embedding, we can switch back to a simple checkoutSession.url redirect. But embedding via Payment Element gives you the on-brand modal you wanted.