Files
fotospiel-app/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx
Codex Agent 64a5411fb9 - Reworked the tenant admin login page
- 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.
2025-10-13 12:50:46 +02:00

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