switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.
This commit is contained in:
@@ -10,8 +10,6 @@ import {
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
||||
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
@@ -26,30 +24,15 @@ import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PA
|
||||
import { useTenantPackages } from "../hooks/useTenantPackages";
|
||||
import {
|
||||
assignFreeTenantPackage,
|
||||
completeTenantPackagePurchase,
|
||||
createTenantPackagePaymentIntent,
|
||||
createTenantPayPalOrder,
|
||||
captureTenantPayPalOrder,
|
||||
createTenantPaddleCheckout,
|
||||
} from "../../api";
|
||||
import { getStripe } from '@/utils/stripe';
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
||||
|
||||
type StripeCheckoutProps = {
|
||||
clientSecret: string;
|
||||
type PaddleCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
};
|
||||
|
||||
type PayPalCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
function useLocaleFormats(locale: string) {
|
||||
const currencyFormatter = React.useMemo(
|
||||
() =>
|
||||
@@ -86,175 +69,53 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
|
||||
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
setError(t("summary.stripe.notReady"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? t("summary.stripe.genericError"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntent = result.paymentIntent;
|
||||
const paymentMethodId =
|
||||
typeof paymentIntent?.payment_method === "string"
|
||||
? paymentIntent.payment_method
|
||||
: typeof paymentIntent?.id === "string"
|
||||
? paymentIntent.id
|
||||
: null;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
setError(t("summary.stripe.missingPaymentId"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleCheckout = React.useCallback(async () => {
|
||||
try {
|
||||
await completeTenantPackagePurchase({
|
||||
packageId,
|
||||
paymentMethodId,
|
||||
});
|
||||
setStatus('processing');
|
||||
setError(null);
|
||||
const { checkout_url } = await createTenantPaddleCheckout(packageId);
|
||||
window.open(checkout_url, '_blank', 'noopener');
|
||||
setStatus('success');
|
||||
onSuccess();
|
||||
} catch (purchaseError) {
|
||||
console.error("[Onboarding] Purchase completion failed", purchaseError);
|
||||
setError(
|
||||
purchaseError instanceof Error
|
||||
? purchaseError.message
|
||||
: t("summary.stripe.completionFailed")
|
||||
);
|
||||
setSubmitting(false);
|
||||
} catch (err) {
|
||||
console.error('[Onboarding] Paddle checkout failed', err);
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
|
||||
}
|
||||
};
|
||||
}, [packageId, onSuccess, t]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
|
||||
<PaymentElement id="payment-element" />
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
||||
<p className="text-sm font-medium text-brand-slate">{t('summary.paddle.heading')}</p>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
|
||||
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !stripe || !elements}
|
||||
size="lg"
|
||||
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
|
||||
disabled={status === 'processing'}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
{submitting ? (
|
||||
{status === 'processing' ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
{t("summary.stripe.submitting")}
|
||||
{t('summary.paddle.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 size-4" />
|
||||
{t("summary.stripe.submit")}
|
||||
{t('summary.paddle.cta')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) {
|
||||
const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleCreateOrder = React.useCallback(async () => {
|
||||
try {
|
||||
setStatus("creating");
|
||||
const orderId = await createTenantPayPalOrder(packageId);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
return orderId;
|
||||
} catch (err) {
|
||||
console.error("[Onboarding] PayPal create order failed", err);
|
||||
setStatus("error");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("summary.paypal.createFailed")
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, [packageId, t]);
|
||||
|
||||
const handleApprove = React.useCallback(
|
||||
async (orderId: string) => {
|
||||
try {
|
||||
setStatus("capturing");
|
||||
await captureTenantPayPalOrder(orderId);
|
||||
setStatus("success");
|
||||
setError(null);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error("[Onboarding] PayPal capture failed", err);
|
||||
setStatus("error");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("summary.paypal.captureFailed")
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onSuccess, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
||||
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<PayPalButtons
|
||||
style={{ layout: "vertical" }}
|
||||
forceReRender={[packageId, currency]}
|
||||
createOrder={async () => handleCreateOrder()}
|
||||
onApprove={async (data) => {
|
||||
if (!data.orderID) {
|
||||
setError(t("summary.paypal.missingOrderId"));
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
await handleApprove(data.orderID);
|
||||
}}
|
||||
onError={(err) => {
|
||||
console.error("[Onboarding] PayPal onError", err);
|
||||
setStatus("error");
|
||||
setError(t("summary.paypal.genericError"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
setStatus("idle");
|
||||
setError(t("summary.paypal.cancelled"));
|
||||
}}
|
||||
disabled={status === "creating" || status === "capturing"}
|
||||
/>
|
||||
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
|
||||
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -267,7 +128,6 @@ export default function WelcomeOrderSummaryPage() {
|
||||
const { t, i18n } = useTranslation("onboarding");
|
||||
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
||||
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
||||
const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
||||
|
||||
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
||||
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
||||
@@ -295,48 +155,9 @@ export default function WelcomeOrderSummaryPage() {
|
||||
const isSubscription = Boolean(packageDetails?.features?.subscription);
|
||||
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
|
||||
|
||||
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
|
||||
const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle");
|
||||
const [intentError, setIntentError] = React.useState<string | null>(null);
|
||||
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!requiresPayment || !packageDetails) {
|
||||
setClientSecret(null);
|
||||
setIntentStatus("idle");
|
||||
setIntentError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
setIntentError(t("summary.stripe.missingKey"));
|
||||
setIntentStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIntentStatus("loading");
|
||||
setIntentError(null);
|
||||
|
||||
createTenantPackagePaymentIntent(packageDetails.id)
|
||||
.then((secret) => {
|
||||
if (cancelled) return;
|
||||
setClientSecret(secret);
|
||||
setIntentStatus("ready");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Onboarding] Payment intent failed", error);
|
||||
if (cancelled) return;
|
||||
setIntentStatus("error");
|
||||
setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed"));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [requiresPayment, packageDetails, stripePromise, t]);
|
||||
|
||||
const priceText =
|
||||
progress.selectedPackage?.priceText ??
|
||||
(packageDetails && typeof packageDetails.price === "number"
|
||||
@@ -534,63 +355,16 @@ export default function WelcomeOrderSummaryPage() {
|
||||
)}
|
||||
|
||||
{requiresPayment && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
|
||||
{intentStatus === "loading" && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-4 animate-spin text-brand-rose" />
|
||||
{t("summary.stripe.loading")}
|
||||
</div>
|
||||
)}
|
||||
{intentStatus === "error" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("summary.stripe.unavailableTitle")}</AlertTitle>
|
||||
<AlertDescription>{intentError ?? t("summary.stripe.unavailableDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{intentStatus === "ready" && clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<StripeCheckoutForm
|
||||
clientSecret={clientSecret}
|
||||
packageId={packageDetails.id}
|
||||
onSuccess={() => {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paypalClientId ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.paypal.sectionTitle")}</h4>
|
||||
<PayPalScriptProvider
|
||||
options={{
|
||||
clientId: paypalClientId,
|
||||
"client-id": paypalClientId,
|
||||
currency: "EUR",
|
||||
intent: "CAPTURE",
|
||||
}}
|
||||
>
|
||||
<PayPalCheckout
|
||||
packageId={packageDetails.id}
|
||||
onSuccess={() => {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
|
||||
<AlertTitle>{t("summary.paypal.notConfiguredTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.paypal.notConfiguredDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
|
||||
<PaddleCheckout
|
||||
packageId={packageDetails.id}
|
||||
onSuccess={() => {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -634,4 +408,4 @@ export default function WelcomeOrderSummaryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export { StripeCheckoutForm, PayPalCheckout };
|
||||
export { PaddleCheckout };
|
||||
|
||||
Reference in New Issue
Block a user