- Brand/Theming: Marketing-Farb- und Typographievariablen in `resources/css/app.css` eingeführt, AdminLayout, Dashboardkarten und Onboarding-Komponenten entsprechend angepasst; Dokumentation (`docs/todo/tenant-admin-onboarding-fusion.md`, `docs/changes/...`) aktualisiert. - Checkout & Payments: Checkout-, PayPal-Controller und Tests für integrierte Stripe/PayPal-Flows sowie Paket-Billing-Abläufe überarbeitet; neue PayPal SDK-Factory und Admin-API-Helper (`resources/js/admin/api.ts`) schaffen Grundlage für Billing/Members/Tasks-Seiten. - DX & Tests: Neue Playwright/E2E-Struktur (docs/testing/e2e.md, `tests/e2e/tenant-onboarding-flow.test.ts`, Utilities), E2E-Tenant-Seeder und zusätzliche Übersetzungen/Factories zur Unterstützung der neuen Flows. - Marketing-Kommunikation: Automatische Kontakt-Bestätigungsmail (`ContactConfirmation` + Blade-Template) implementiert; Guest-PWA unter `/event` erreichbar. - Nebensitzung: Blogsystem gefixt und umfassenden BlogPostSeeder für Beispielinhalte angelegt.
200 lines
7.9 KiB
TypeScript
200 lines
7.9 KiB
TypeScript
import React from 'react';
|
||
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
TenantWelcomeLayout,
|
||
WelcomeStepCard,
|
||
OnboardingCTAList,
|
||
useOnboardingProgress,
|
||
} from '..';
|
||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||
import { Button } from '@/components/ui/button';
|
||
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from '../../constants';
|
||
import { useTenantPackages } from '../hooks/useTenantPackages';
|
||
import { Package } from '../../api';
|
||
|
||
export default function WelcomePackagesPage() {
|
||
const navigate = useNavigate();
|
||
const { markStep } = useOnboardingProgress();
|
||
const packagesState = useTenantPackages();
|
||
|
||
React.useEffect(() => {
|
||
if (packagesState.status === 'success') {
|
||
markStep({
|
||
packageSelected: Boolean(packagesState.activePackage),
|
||
lastStep: 'packages',
|
||
selectedPackage: packagesState.activePackage
|
||
? {
|
||
id: packagesState.activePackage.package_id,
|
||
name: packagesState.activePackage.package_name,
|
||
priceText: packagesState.activePackage.price
|
||
? new Intl.NumberFormat('de-DE', {
|
||
style: 'currency',
|
||
currency: packagesState.activePackage.currency ?? 'EUR',
|
||
minimumFractionDigits: 0,
|
||
}).format(packagesState.activePackage.price)
|
||
: null,
|
||
isSubscription: false,
|
||
}
|
||
: null,
|
||
});
|
||
}
|
||
}, [packagesState, markStep]);
|
||
|
||
const handleSelectPackage = React.useCallback(
|
||
(pkg: Package) => {
|
||
const priceText =
|
||
typeof pkg.price === 'number'
|
||
? new Intl.NumberFormat('de-DE', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
minimumFractionDigits: 0,
|
||
}).format(pkg.price)
|
||
: 'Auf Anfrage';
|
||
|
||
markStep({
|
||
packageSelected: true,
|
||
lastStep: package-,
|
||
selectedPackage: {
|
||
id: pkg.id,
|
||
name: pkg.name,
|
||
priceText,
|
||
isSubscription: Boolean(pkg.features?.subscription),
|
||
},
|
||
});
|
||
|
||
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
|
||
},
|
||
[markStep, navigate]
|
||
);
|
||
|
||
return (
|
||
<TenantWelcomeLayout
|
||
eyebrow="Schritt 2"
|
||
title="W<>hle dein Eventpaket"
|
||
subtitle="Fotospiel unterst<73>tzt flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
|
||
>
|
||
<WelcomeStepCard
|
||
step={2}
|
||
totalSteps={4}
|
||
title="Aktiviere die passenden Credits"
|
||
description="Sichere dir Kapazit<69>t f<>r dein n<>chstes Event. Du kannst jederzeit upgraden <20> bezahle nur, was du wirklich brauchst."
|
||
icon={CreditCard}
|
||
>
|
||
{packagesState.status === 'loading' && (
|
||
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
|
||
<Loader2 className="size-5 animate-spin text-brand-rose" />
|
||
Pakete werden geladen <EFBFBD>
|
||
</div>
|
||
)}
|
||
|
||
{packagesState.status === 'error' && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="size-4" />
|
||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||
<AlertDescription>{packagesState.message}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{packagesState.status === 'success' && (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{packagesState.catalog.map((pkg) => {
|
||
const isActive = packagesState.activePackage?.package_id === pkg.id;
|
||
const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id);
|
||
const featureLabels = Object.entries(pkg.features ?? {})
|
||
.filter(([, enabled]) => Boolean(enabled))
|
||
.map(([key]) => key.replace(/_/g, ' '));
|
||
|
||
const isSubscription = Boolean(pkg.features?.subscription);
|
||
const priceText =
|
||
typeof pkg.price === 'number'
|
||
? new Intl.NumberFormat('de-DE', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
minimumFractionDigits: 0,
|
||
}).format(pkg.price)
|
||
: 'Auf Anfrage';
|
||
|
||
return (
|
||
<div
|
||
key={pkg.id}
|
||
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
|
||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||
</p>
|
||
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
|
||
</div>
|
||
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
|
||
</div>
|
||
<p className="text-sm text-brand-navy/80">
|
||
{pkg.max_photos
|
||
? Bis zu Fotos inklusive <EFBFBD> perfekt f<EFBFBD>r lebendige Reportagen.
|
||
: 'Sofort einsatzbereit f<>r dein n<>chstes Event.'}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||
{pkg.max_guests && (
|
||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.max_guests} G<EFBFBD>ste}</span>
|
||
)}
|
||
{pkg.gallery_days && (
|
||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.gallery_days} Tage Galerie}</span>
|
||
)}
|
||
{featureLabels.map((feature) => (
|
||
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||
{feature}
|
||
</span>
|
||
))}
|
||
{isActive && (
|
||
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">Aktives Paket</span>
|
||
)}
|
||
</div>
|
||
<Button
|
||
size="lg"
|
||
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
|
||
onClick={() => handleSelectPackage(pkg)}
|
||
>
|
||
Paket w<EFBFBD>hlen
|
||
<ArrowRight className="ml-2 size-4" />
|
||
</Button>
|
||
{purchased && (
|
||
<p className="text-xs text-brand-rose">
|
||
Bereits gekauft am{' '}
|
||
{purchased.purchased_at
|
||
? new Date(purchased.purchased_at).toLocaleDateString('de-DE')
|
||
: 'unbekanntem Datum'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</WelcomeStepCard>
|
||
|
||
<OnboardingCTAList
|
||
actions={[
|
||
{
|
||
id: 'skip-to-checkout',
|
||
label: 'Direkt zum Billing',
|
||
description:
|
||
'Falls du schon wei<65>t, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.',
|
||
buttonLabel: 'Billing <20>ffnen',
|
||
href: ADMIN_BILLING_PATH,
|
||
icon: ShoppingBag,
|
||
variant: 'secondary',
|
||
},
|
||
{
|
||
id: 'continue',
|
||
label: 'Bestell<6C>bersicht anzeigen',
|
||
description: 'Pr<50>fe Paketdetails und entscheide, ob du direkt zahlen oder sp<73>ter fortfahren m<>chtest.',
|
||
buttonLabel: 'Weiter zur <20>bersicht',
|
||
onClick: () => navigate(ADMIN_WELCOME_SUMMARY_PATH),
|
||
icon: ArrowRight,
|
||
},
|
||
]}
|
||
/>
|
||
</TenantWelcomeLayout>
|
||
);
|
||
} |