- Updated the User model to implement Filament’s tenancy contracts - Seeded a ready-to-use demo tenant (user, tenant, active package, purchase) - Introduced a branded, translated 403 error page to replace the generic forbidden message for unauthorised admin hits - Removed the public “Register” links from the marketing header - hardened join event logic and improved error handling in the guest pwa.
638 lines
23 KiB
TypeScript
638 lines
23 KiB
TypeScript
import React from "react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Receipt,
|
|
ShieldCheck,
|
|
ArrowRight,
|
|
ArrowLeft,
|
|
CreditCard,
|
|
AlertTriangle,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
|
import { loadStripe } from "@stripe/stripe-js";
|
|
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
|
|
|
import {
|
|
TenantWelcomeLayout,
|
|
WelcomeStepCard,
|
|
OnboardingCTAList,
|
|
useOnboardingProgress,
|
|
} from "..";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
|
|
import { useTenantPackages } from "../hooks/useTenantPackages";
|
|
import {
|
|
assignFreeTenantPackage,
|
|
completeTenantPackagePurchase,
|
|
createTenantPackagePaymentIntent,
|
|
createTenantPayPalOrder,
|
|
captureTenantPayPalOrder,
|
|
} from "../../api";
|
|
|
|
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
|
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
|
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
|
|
|
|
type StripeCheckoutProps = {
|
|
clientSecret: string;
|
|
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(
|
|
() =>
|
|
new Intl.NumberFormat(locale, {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
minimumFractionDigits: 0,
|
|
}),
|
|
[locale]
|
|
);
|
|
|
|
const dateFormatter = React.useMemo(
|
|
() =>
|
|
new Intl.DateTimeFormat(locale, {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
}),
|
|
[locale]
|
|
);
|
|
|
|
return { currencyFormatter, dateFormatter };
|
|
}
|
|
|
|
function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string): string {
|
|
const translationKey = `summary.details.features.${key}`;
|
|
const translated = t(translationKey);
|
|
if (translated !== translationKey) {
|
|
return translated;
|
|
}
|
|
return key
|
|
.split("_")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
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;
|
|
}
|
|
|
|
try {
|
|
await completeTenantPackagePurchase({
|
|
packageId,
|
|
paymentMethodId,
|
|
});
|
|
onSuccess();
|
|
} catch (purchaseError) {
|
|
console.error("[Onboarding] Purchase completion failed", purchaseError);
|
|
setError(
|
|
purchaseError instanceof Error
|
|
? purchaseError.message
|
|
: t("summary.stripe.completionFailed")
|
|
);
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
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>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<Button
|
|
type="submit"
|
|
disabled={submitting || !stripe || !elements}
|
|
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"
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
{t("summary.stripe.submitting")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CreditCard className="mr-2 size-4" />
|
|
{t("summary.stripe.submit")}
|
|
</>
|
|
)}
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function WelcomeOrderSummaryPage() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { progress, markStep } = useOnboardingProgress();
|
|
const packagesState = useTenantPackages();
|
|
const { t, i18n } = useTranslation("onboarding");
|
|
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
|
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
|
|
|
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
|
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
|
|
|
React.useEffect(() => {
|
|
if (!selectedPackageId && packagesState.status !== "loading") {
|
|
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
|
|
}
|
|
}, [selectedPackageId, packagesState.status, navigate]);
|
|
|
|
React.useEffect(() => {
|
|
markStep({ lastStep: "summary" });
|
|
}, [markStep]);
|
|
|
|
const packageDetails =
|
|
packagesState.status === "success" && selectedPackageId
|
|
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
|
|
: null;
|
|
|
|
const activePackage =
|
|
packagesState.status === "success"
|
|
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
|
|
: null;
|
|
|
|
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, t]);
|
|
|
|
const priceText =
|
|
progress.selectedPackage?.priceText ??
|
|
(packageDetails && typeof packageDetails.price === "number"
|
|
? currencyFormatter.format(packageDetails.price)
|
|
: null);
|
|
|
|
const detailBadges = React.useMemo(() => {
|
|
if (!packageDetails) {
|
|
return [] as string[];
|
|
}
|
|
const badges: string[] = [];
|
|
if (packageDetails.max_photos) {
|
|
badges.push(t("summary.details.photos", { count: packageDetails.max_photos }));
|
|
}
|
|
if (packageDetails.gallery_days) {
|
|
badges.push(t("summary.details.galleryDays", { count: packageDetails.gallery_days }));
|
|
}
|
|
if (packageDetails.max_guests) {
|
|
badges.push(t("summary.details.guests", { count: packageDetails.max_guests }));
|
|
}
|
|
return badges;
|
|
}, [packageDetails, t]);
|
|
|
|
const featuresList = React.useMemo(() => {
|
|
if (!packageDetails) {
|
|
return [] as string[];
|
|
}
|
|
return Object.entries(packageDetails.features ?? {})
|
|
.filter(([, enabled]) => Boolean(enabled))
|
|
.map(([feature]) => humanizeFeature(t, feature));
|
|
}, [packageDetails, t]);
|
|
|
|
const nextSteps = t("summary.nextSteps", { returnObjects: true }) as string[];
|
|
|
|
return (
|
|
<TenantWelcomeLayout
|
|
eyebrow={t("summary.layout.eyebrow")}
|
|
title={t("summary.layout.title")}
|
|
subtitle={t("summary.layout.subtitle")}
|
|
footer={
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-2 text-sm font-medium text-brand-rose hover:text-rose-600"
|
|
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
{t("summary.footer.back")}
|
|
</button>
|
|
}
|
|
>
|
|
<WelcomeStepCard
|
|
step={3}
|
|
totalSteps={4}
|
|
title={t("summary.step.title")}
|
|
description={t("summary.step.description")}
|
|
icon={Receipt}
|
|
>
|
|
{packagesState.status === "loading" && (
|
|
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
|
|
<CreditCard className="size-5 text-brand-rose" />
|
|
{t("summary.state.loading")}
|
|
</div>
|
|
)}
|
|
|
|
{packagesState.status === "error" && (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle className="size-4" />
|
|
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
|
|
<AlertDescription>
|
|
{packagesState.message ?? t("summary.state.errorDescription")}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{packagesState.status === "success" && !packageDetails && (
|
|
<Alert>
|
|
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
|
|
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{packagesState.status === "success" && packageDetails && (
|
|
<div className="grid gap-4">
|
|
<div className="rounded-3xl border border-brand-rose-soft bg-brand-card p-6 shadow-md shadow-rose-100/40">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
|
|
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
|
|
</p>
|
|
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
|
|
</div>
|
|
{priceText && (
|
|
<Badge className="rounded-full bg-rose-100 px-4 py-2 text-base font-semibold text-rose-600">
|
|
{priceText}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
|
|
<div>
|
|
<dt className="font-semibold text-brand-slate">{t("summary.details.section.photosTitle")}</dt>
|
|
<dd>
|
|
{packageDetails.max_photos
|
|
? t("summary.details.section.photosValue", {
|
|
count: packageDetails.max_photos,
|
|
days: packageDetails.gallery_days ?? t("summary.details.infinity"),
|
|
})
|
|
: t("summary.details.section.photosUnlimited")}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-semibold text-brand-slate">{t("summary.details.section.guestsTitle")}</dt>
|
|
<dd>
|
|
{packageDetails.max_guests
|
|
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
|
|
: t("summary.details.section.guestsUnlimited")}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-semibold text-brand-slate">{t("summary.details.section.featuresTitle")}</dt>
|
|
<dd>{featuresList.length ? featuresList.join(", ") : t("summary.details.section.featuresNone")}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-semibold text-brand-slate">{t("summary.details.section.statusTitle")}</dt>
|
|
<dd>{activePackage ? t("summary.details.section.statusActive") : t("summary.details.section.statusInactive")}</dd>
|
|
</div>
|
|
</dl>
|
|
{detailBadges.length > 0 && (
|
|
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
|
{detailBadges.map((badge) => (
|
|
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
|
{badge}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!activePackage && (
|
|
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
|
|
<ShieldCheck className="size-4" />
|
|
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
|
|
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{packageDetails.price === 0 && (
|
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
|
|
<p className="text-sm text-emerald-700">{t("summary.free.description")}</p>
|
|
{freeAssignStatus === "success" ? (
|
|
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
|
|
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
|
|
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<Button
|
|
onClick={async () => {
|
|
if (!packageDetails) {
|
|
return;
|
|
}
|
|
setFreeAssignStatus("loading");
|
|
setFreeAssignError(null);
|
|
try {
|
|
await assignFreeTenantPackage(packageDetails.id);
|
|
setFreeAssignStatus("success");
|
|
markStep({ packageSelected: true });
|
|
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
|
} catch (error) {
|
|
console.error("[Onboarding] Free package assignment failed", error);
|
|
setFreeAssignStatus("error");
|
|
setFreeAssignError(
|
|
error instanceof Error ? error.message : t("summary.free.errorMessage")
|
|
);
|
|
}
|
|
}}
|
|
disabled={freeAssignStatus === "loading"}
|
|
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
|
|
>
|
|
{freeAssignStatus === "loading" ? (
|
|
<>
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
{t("summary.free.progress")}
|
|
</>
|
|
) : (
|
|
t("summary.free.activate")
|
|
)}
|
|
</Button>
|
|
)}
|
|
{freeAssignStatus === "error" && freeAssignError && (
|
|
<Alert variant="destructive" className="mt-3">
|
|
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
|
|
<AlertDescription>{freeAssignError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
|
|
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.nextStepsTitle")}</h4>
|
|
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
|
|
{nextSteps.map((step, index) => (
|
|
<li key={`${step}-${index}`}>{step}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</WelcomeStepCard>
|
|
|
|
<OnboardingCTAList
|
|
actions={[
|
|
{
|
|
id: "checkout",
|
|
label: t("summary.cta.billing.label"),
|
|
description: t("summary.cta.billing.description"),
|
|
buttonLabel: t("summary.cta.billing.button"),
|
|
href: ADMIN_BILLING_PATH,
|
|
icon: CreditCard,
|
|
variant: "secondary",
|
|
},
|
|
{
|
|
id: "continue-to-setup",
|
|
label: t("summary.cta.setup.label"),
|
|
description: t("summary.cta.setup.description"),
|
|
buttonLabel: t("summary.cta.setup.button"),
|
|
onClick: () => {
|
|
markStep({ lastStep: "event-setup" });
|
|
navigate(ADMIN_WELCOME_EVENT_PATH);
|
|
},
|
|
icon: ArrowRight,
|
|
},
|
|
]}
|
|
/>
|
|
</TenantWelcomeLayout>
|
|
);
|
|
}
|
|
|
|
export { StripeCheckoutForm, PayPalCheckout };
|