diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index 73127ec..1ccc162 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -169,15 +169,15 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand
-
-
+
+

{t('app.brand')}

{title}

{subtitle ?

{subtitle}

: null}
-
+
{disableCommandShelf ? : null} {actions} diff --git a/resources/js/admin/components/CommandShelf.tsx b/resources/js/admin/components/CommandShelf.tsx index e5e419c..c43c383 100644 --- a/resources/js/admin/components/CommandShelf.tsx +++ b/resources/js/admin/components/CommandShelf.tsx @@ -31,7 +31,6 @@ import { ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH, - ADMIN_EVENT_TOOLKIT_PATH, } from '../constants'; import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; @@ -192,11 +191,11 @@ export function CommandShelf() { href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug), }, { - key: 'toolkit', - label: t('commandShelf.actions.toolkit.label', 'Event-Day Toolkit'), + key: 'members', + label: t('eventMenu.guests', 'Team & Gäste'), description: t('commandShelf.actions.toolkit.desc', 'Broadcasts, Aufgaben & Quicklinks.'), icon: MessageSquare, - href: ADMIN_EVENT_TOOLKIT_PATH(slug), + href: ADMIN_EVENT_MEMBERS_PATH(slug), }, ]; @@ -275,7 +274,7 @@ export function CommandShelf() { size="sm" variant="ghost" className="rounded-full text-rose-600 hover:bg-rose-50 dark:text-rose-200 dark:hover:bg-rose-200/10" - onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))} + onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))} > {t('commandShelf.cta.toolkit', 'Event-Day öffnen')} diff --git a/resources/js/admin/components/EventNav.tsx b/resources/js/admin/components/EventNav.tsx index 3a7eb85..19b1582 100644 --- a/resources/js/admin/components/EventNav.tsx +++ b/resources/js/admin/components/EventNav.tsx @@ -22,7 +22,6 @@ import { ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH, - ADMIN_EVENT_TOOLKIT_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH, } from '../constants'; @@ -37,7 +36,6 @@ function buildEventLinks(slug: string, t: ReturnType['t'] { key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) }, { key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) }, { key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) }, - { key: 'toolkit', label: t('eventMenu.toolkit', 'Toolkit'), href: ADMIN_EVENT_TOOLKIT_PATH(slug) }, ]; } diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 4323d07..d5ad5e5 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -29,7 +29,6 @@ export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/event export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`); export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`); export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`); -export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`); export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`); export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`); export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`); diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 5be04e1..f90310d 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -99,5 +99,133 @@ "hint": "Bitte versuche es erneut oder lade die Seite neu.", "retry": "Erneut laden" } + }, + "welcome": { + "eyebrow": "Event Admin", + "title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.", + "subtitle": "Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.", + "badge": "Fotos, Aufgaben & Einladungen an einem Ort", + "loginPrompt": "Bereits Kunde? Login oben rechts.", + "cta": { + "login": "Login", + "open": "Event Admin öffnen", + "packages": "Pakete ansehen", + "how": "So funktioniert's" + }, + "stats": { + "events": "Events begleitet", + "photos": "Fotos moderiert", + "teams": "Teams" + }, + "features": { + "title": "Was du steuern kannst", + "subtitle": "Alles an einem Ort", + "branding": { + "title": "Branding & Layout", + "description": "Farben, Schriften, QR-Layouts und Einladungen in einem Fluss." + }, + "tasks": { + "title": "Aufgaben & Emotion-Sets", + "description": "Sammlungen importieren oder eigene Aufgaben erstellen – mobil abhakbar." + }, + "moderation": { + "title": "Foto-Moderation", + "description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen." + }, + "invites": { + "title": "Einladungen & QR", + "description": "Links und Druckvorlagen generieren – mit Paketlimits im Blick." + } + }, + "steps": { + "title": "So funktioniert's", + "subtitle": "In drei Schritten bereit", + "prepare": { + "title": "Vorbereiten", + "description": "Event anlegen, Branding setzen, Aufgaben aktivieren.", + "accent": "Setup" + }, + "share": { + "title": "Teilen & Einladen", + "description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.", + "accent": "Share" + }, + "run": { + "title": "Live moderieren", + "description": "Uploads prüfen, Highlights pushen und nach dem Event die Galerie teilen.", + "accent": "Live" + } + }, + "plans": { + "title": "Pakete im Überblick", + "subtitle": "Wähle das passende Kontingent", + "hint": "Starter, Standard oder Reseller – alles mit Moderation & Einladungen.", + "starter": { + "title": "Starter", + "badge": "Für ein Event", + "p1": "1 Event, Basis-Branding", + "p2": "Aufgaben & Einladungen inklusive", + "p3": "Moderation & Galerie-Link" + }, + "standard": { + "title": "Standard", + "badge": "Beliebt", + "highlight": "Mehr Kontingent & Branding", + "p1": "Mehr Events pro Jahr", + "p2": "Erweitertes Branding & Layouts", + "p3": "Support bei Live-Events" + }, + "reseller": { + "title": "Reseller S", + "badge": "Für Dienstleister", + "highlight": "Mehrere Events parallel verwalten", + "p1": "Bis zu 5 Events pro Paket", + "p2": "Aufgaben-Sammlungen und Vorlagen", + "p3": "Teamrollen & Rechteverwaltung" + } + }, + "audience": { + "title": "Für wen?", + "subtitle": "Endkunden & Reseller im Blick", + "endcustomers": { + "title": "Endkund:innen", + "description": "Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen." + }, + "resellers": { + "title": "Reseller & Agenturen", + "description": "Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen." + }, + "cta": "Wenige Klicks bis zum Start" + }, + "preview": { + "title": "Was dich erwartet", + "items": [ + "Moderation, Aufgaben und Einladungen als Schnellzugriff", + "Sticky Actions auf Mobile für den Eventtag", + "Paket-Status & Limits jederzeit sichtbar" + ] + }, + "highlight": { + "moderation": "Live-Moderation", + "moderationHint": "Approve/Hide, Highlights, Galerie-Link", + "tasks": "Aufgaben & Emotion-Sets", + "tasksHint": "Sammlungen importieren oder eigene erstellen" + }, + "theme": { + "label": "Darstellung", + "aria": "Darstellung umschalten", + "light": "Hell", + "dark": "Dunkel" + }, + "menu": { + "open": "Menü öffnen", + "close": "Menü schließen", + "title": "Navigation" + }, + "footer": { + "eyebrow": "Bereit?", + "title": "Melde dich an oder prüfe die Pakete", + "subtitle": "Login für bestehende Kunden, Pakete für neue Teams." + } } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index e7ae1aa..1217ff5 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -99,5 +99,133 @@ "hint": "Please try again or refresh the page.", "retry": "Retry" } + }, + "welcome": { + "eyebrow": "Event Admin", + "title": "Event branding, tasks & photo moderation in one app.", + "subtitle": "Prepare your event, share invites, moderate uploads live and release the gallery afterwards.", + "badge": "Photos, tasks & invites in one place", + "loginPrompt": "Already a customer? Login in the top right.", + "cta": { + "login": "Login", + "open": "Open Event Admin", + "packages": "View packages", + "how": "How it works" + }, + "stats": { + "events": "Events supported", + "photos": "Photos moderated", + "teams": "Teams" + }, + "features": { + "title": "What you control", + "subtitle": "Everything in one place", + "branding": { + "title": "Branding & Layout", + "description": "Colors, typography, QR layouts and invites in one flow." + }, + "tasks": { + "title": "Tasks & Emotion sets", + "description": "Import collections or create your own tasks – mobile checklists included." + }, + "moderation": { + "title": "Photo moderation", + "description": "Review uploads instantly, mark highlights and share the gallery link." + }, + "invites": { + "title": "Invites & QR", + "description": "Generate links and print templates – with package limits in sight." + } + }, + "steps": { + "title": "How it works", + "subtitle": "Ready in three steps", + "prepare": { + "title": "Prepare", + "description": "Create the event, set branding, enable tasks.", + "accent": "Setup" + }, + "share": { + "title": "Share & Invite", + "description": "Distribute QR/links, pick missions, onboard your team.", + "accent": "Share" + }, + "run": { + "title": "Run live", + "description": "Review uploads, push highlights and share the gallery afterwards.", + "accent": "Live" + } + }, + "plans": { + "title": "Packages at a glance", + "subtitle": "Choose the right quota", + "hint": "Starter, Standard or Reseller – all include moderation & invites.", + "starter": { + "title": "Starter", + "badge": "For one event", + "p1": "1 event, basic branding", + "p2": "Tasks & invites included", + "p3": "Moderation & gallery link" + }, + "standard": { + "title": "Standard", + "badge": "Popular", + "highlight": "More quota & branding", + "p1": "More events per year", + "p2": "Extended branding & layouts", + "p3": "Support on live days" + }, + "reseller": { + "title": "Reseller S", + "badge": "For pros", + "highlight": "Manage multiple events", + "p1": "Up to 5 events per package", + "p2": "Task collections and templates", + "p3": "Team roles & permissions" + } + }, + "audience": { + "title": "Who is it for?", + "subtitle": "Built for hosts and resellers", + "endcustomers": { + "title": "Event hosts", + "description": "Set up fast, moderate on mobile and share the gallery afterwards." + }, + "resellers": { + "title": "Resellers & agencies", + "description": "Track multiple events, monitor quotas and reuse templates." + }, + "cta": "Just a few clicks to go live" + }, + "preview": { + "title": "What to expect", + "items": [ + "Quick access to moderation, tasks and invites", + "Sticky actions on mobile for the event day", + "Package status & limits always visible" + ] + }, + "highlight": { + "moderation": "Live moderation", + "moderationHint": "Approve/Hide, highlights, gallery link", + "tasks": "Tasks & emotion sets", + "tasksHint": "Import collections or create your own" + }, + "theme": { + "label": "Appearance", + "aria": "Toggle appearance", + "light": "Light", + "dark": "Dark" + }, + "menu": { + "open": "Open menu", + "close": "Close menu", + "title": "Navigation" + }, + "footer": { + "eyebrow": "Ready?", + "title": "Log in or view packages", + "subtitle": "Login for existing customers, packages for new teams." + } } } diff --git a/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx b/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx deleted file mode 100644 index 7abd4bc..0000000 --- a/resources/js/admin/onboarding/pages/WelcomeEventSetupPage.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { ClipboardCheck, Sparkles, Globe, ArrowRight } from "lucide-react"; - -import { - TenantWelcomeLayout, - WelcomeStepCard, - OnboardingCTAList, - useOnboardingProgress, -} from ".."; -import { Button } from "@/components/ui/button"; -import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants"; -import { FrostedSurface } from "../../components/tenant"; - -export default function WelcomeEventSetupPage() { - const navigate = useNavigate(); - const { markStep } = useOnboardingProgress(); - const { t } = useTranslation("onboarding"); - - React.useEffect(() => { - markStep({ lastStep: "event-setup" }); - }, [markStep]); - - return ( - - -
- {[ - { - id: "story", - title: t("eventSetup.tiles.story.title"), - copy: t("eventSetup.tiles.story.copy"), - icon: Sparkles, - }, - { - id: "team", - title: t("eventSetup.tiles.team.title"), - copy: t("eventSetup.tiles.team.copy"), - icon: Globe, - }, - { - id: "launch", - title: t("eventSetup.tiles.launch.title"), - copy: t("eventSetup.tiles.launch.copy"), - icon: ArrowRight, - }, - ].map((item) => ( - - - - -

{item.title}

-

{item.copy}

-
- ))} -
- - -

{t("eventSetup.cta.heading")}

-

{t("eventSetup.cta.description")}

- -
-
- - navigate(-1), - variant: "secondary", - }, - { - id: "dashboard", - label: t("eventSetup.actions.dashboard.label"), - description: t("eventSetup.actions.dashboard.description"), - buttonLabel: t("eventSetup.actions.dashboard.button"), - onClick: () => navigate(ADMIN_HOME_PATH), - }, - { - id: "events", - label: t("eventSetup.actions.events.label"), - description: t("eventSetup.actions.events.description"), - buttonLabel: t("eventSetup.actions.events.button"), - onClick: () => navigate(ADMIN_EVENTS_PATH), - }, - ]} - /> -
- ); -} diff --git a/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx b/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx deleted file mode 100644 index 0cedfd8..0000000 --- a/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Sparkles, Users, Camera, CalendarDays, ChevronRight, CreditCard, UserPlus, Palette } from "lucide-react"; - -import { - TenantWelcomeLayout, - WelcomeHero, - OnboardingHighlightsGrid, - OnboardingCTAList, - useOnboardingProgress, -} from ".."; -import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants"; -import { ChecklistRow, FrostedSurface } from "../../components/tenant"; - -export default function WelcomeLandingPage() { - const navigate = useNavigate(); - const { markStep, progress } = useOnboardingProgress(); - const { t } = useTranslation("onboarding"); - - React.useEffect(() => { - markStep({ welcomeSeen: true, lastStep: "landing" }); - }, [markStep]); - - const progressStatus = React.useMemo( - () => ({ - complete: t("landingProgress.status.complete"), - pending: t("landingProgress.status.pending"), - }), - [t] - ); - - const progressSteps = React.useMemo( - () => [ - { - key: "package", - icon: , - label: t("landingProgress.steps.package.title"), - hint: t("landingProgress.steps.package.hint"), - completed: progress.packageSelected, - }, - { - key: "invite", - icon: , - label: t("landingProgress.steps.invite.title"), - hint: t("landingProgress.steps.invite.hint"), - completed: progress.inviteCreated, - }, - { - key: "branding", - icon: , - label: t("landingProgress.steps.branding.title"), - hint: t("landingProgress.steps.branding.hint"), - completed: progress.brandingConfigured, - }, - ], - [progress.packageSelected, progress.inviteCreated, progress.brandingConfigured, t] - ); - - return ( - - {t("layout.alreadyFamiliar")} - - - } - > - navigate(ADMIN_WELCOME_PACKAGES_PATH), - icon: Sparkles, - }, - { - label: t("hero.secondary.label"), - onClick: () => navigate(ADMIN_EVENTS_PATH), - icon: CalendarDays, - variant: "outline", - }, - ]} - /> - - -
-

- {t("landingProgress.eyebrow")} -

-

- {t("landingProgress.title")} -

-

- {t("landingProgress.description")} -

-
-
- {progressSteps.map((step) => ( - - ))} -
-
- - - - navigate(ADMIN_WELCOME_PACKAGES_PATH), - icon: Sparkles, - buttonLabel: t("ctaList.choosePackage.button"), - }, - { - id: "create-event", - label: t("ctaList.createEvent.label"), - description: t("ctaList.createEvent.description"), - onClick: () => navigate(ADMIN_EVENTS_PATH), - icon: CalendarDays, - buttonLabel: t("ctaList.createEvent.button"), - variant: "secondary", - }, - ]} - /> -
- ); -} diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx deleted file mode 100644 index 24d3928..0000000 --- a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx +++ /dev/null @@ -1,511 +0,0 @@ -import React from "react"; -import { useNavigate, useLocation } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - Receipt, - ShieldCheck, - ArrowRight, - ArrowLeft, - CreditCard, - AlertTriangle, - Info, - Loader2, -} from "lucide-react"; - -import { - TenantWelcomeLayout, - WelcomeStepCard, - OnboardingCTAList, - useOnboardingProgress, -} from ".."; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { FrostedSurface } from "../../components/tenant"; -import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants"; -import { useTenantPackages } from "../hooks/useTenantPackages"; -import { - assignFreeTenantPackage, - createTenantPaddleCheckout, -} from "../../api"; -import { cn } from "@/lib/utils"; - -type PaddleCheckoutProps = { - packageId: number; - onSuccess: () => void; - t: ReturnType["t"]; - className?: 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["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 PaddleCheckout({ packageId, onSuccess, t, className }: PaddleCheckoutProps) { - const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle'); - const [error, setError] = React.useState(null); - - const handleCheckout = React.useCallback(async () => { - try { - setStatus('processing'); - setError(null); - const { checkout_url } = await createTenantPaddleCheckout(packageId); - window.open(checkout_url, '_blank', 'noopener'); - setStatus('success'); - onSuccess(); - } 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 ( - -
-

- {t('summary.paddle.sectionTitle')} -

-

- {t('summary.paddle.heading')} -

-
- {error ? ( -
- -
-

{t('summary.paddle.errorTitle')}

-

{error}

-
-
- ) : null} - -

{t('summary.paddle.hint')}

-
- ); -} - -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 } = useLocaleFormats(locale); - - const packageIdFromState = - typeof location.state === "object" && location.state !== null && "packageId" in location.state - ? (location.state as { packageId?: number | string | null }).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 [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle"); - const [freeAssignError, setFreeAssignError] = React.useState(null); - - 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 ( - navigate(ADMIN_WELCOME_PACKAGES_PATH)} - > - - {t("summary.footer.back")} - - } - > - - {packagesState.status === "loading" && ( - - - {t("summary.state.loading")} - - )} - - {packagesState.status === "error" && ( - - -
-

- {t("summary.state.errorTitle")} -

-

- {packagesState.message ?? t("summary.state.errorDescription")} -

-
-
- )} - - {packagesState.status === "success" && !packageDetails && ( - - -
-

- {t("summary.state.missingTitle")} -

-

- {t("summary.state.missingDescription")} -

-
-
- )} - - {packagesState.status === "success" && packageDetails && ( -
- -
-
-
-
-
- - {t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")} - -

- {packageDetails.name} -

-
-
- {priceText ? ( - - {priceText} - - ) : null} - - {activePackage - ? t("summary.details.section.statusActive") - : t("summary.details.section.statusInactive")} - -
-
- -
-
-
- {t("summary.details.section.photosTitle")} -
-
- {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")} -
-
-
-
- {t("summary.details.section.guestsTitle")} -
-
- {packageDetails.max_guests - ? t("summary.details.section.guestsValue", { count: packageDetails.max_guests }) - : t("summary.details.section.guestsUnlimited")} -
-
-
-
- {t("summary.details.section.featuresTitle")} -
-
- {featuresList.length ? ( -
- {featuresList.map((feature) => ( - - {feature} - - ))} -
- ) : ( - {t("summary.details.section.featuresNone")} - )} -
-
-
-
- {t("summary.details.section.statusTitle")} -
-
- - {activePackage - ? t("summary.details.section.statusActive") - : t("summary.details.section.statusInactive")} - -
-
-
- - {detailBadges.length > 0 ? ( -
- {detailBadges.map((badge) => ( - - {badge} - - ))} -
- ) : null} -
- - - {!activePackage && ( - - -
-

- {t("summary.status.pendingTitle")} -

-

- {t("summary.status.pendingDescription")} -

-
-
- )} - - {packageDetails.price === 0 && ( - -

{t("summary.free.description")}

- {freeAssignStatus === "success" ? ( -
-

{t("summary.free.successTitle")}

-

{t("summary.free.successDescription")}

-
- ) : ( - - )} - {freeAssignStatus === "error" && freeAssignError ? ( -
-

{t("summary.free.failureTitle")}

-

{freeAssignError}

-
- ) : null} -
- )} - - {requiresPayment && ( - { - markStep({ packageSelected: true }); - navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true }); - }} - t={t} - /> - )} - - -

{t("summary.nextStepsTitle")}

-
    - {nextSteps.map((step, index) => ( -
  1. {step}
  2. - ))} -
-
-
- )} - - - { - markStep({ lastStep: "event-setup" }); - navigate(ADMIN_WELCOME_EVENT_PATH); - }, - icon: ArrowRight, - }, - ]} - /> - - ); -} - -export { PaddleCheckout }; diff --git a/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx b/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx deleted file mode 100644 index 3d4e474..0000000 --- a/resources/js/admin/onboarding/pages/WelcomePackagesPage.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React from "react"; -import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from "lucide-react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -import { - TenantWelcomeLayout, - WelcomeStepCard, - OnboardingCTAList, - useOnboardingProgress, -} from ".."; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { FrostedSurface } from "../../components/tenant/frosted-surface"; -import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants"; -import { useTenantPackages } from "../hooks/useTenantPackages"; -import { Package } from "../../api"; - -const DEFAULT_CURRENCY = "EUR"; - -function useLocaleFormats(locale: string) { - const currencyFormatter = React.useMemo( - () => - new Intl.NumberFormat(locale, { - style: "currency", - currency: DEFAULT_CURRENCY, - minimumFractionDigits: 0, - }), - [locale] - ); - - const dateFormatter = React.useMemo( - () => - new Intl.DateTimeFormat(locale, { - year: "numeric", - month: "2-digit", - day: "2-digit", - }), - [locale] - ); - - return { currencyFormatter, dateFormatter }; -} - -function formatFeatureLabel(t: ReturnType["t"], key: string): string { - const translationKey = `packages.features.${key}`; - const translated = t(translationKey); - if (translated !== translationKey) { - return translated; - } - return key - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -export default function WelcomePackagesPage() { - const navigate = useNavigate(); - const { 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); - - React.useEffect(() => { - if (packagesState.status === "success") { - const active = packagesState.activePackage; - markStep({ - packageSelected: Boolean(active), - lastStep: "packages", - selectedPackage: active - ? { - id: active.package_id, - name: active.package_name, - priceText: - typeof active.price === "number" - ? currencyFormatter.format(active.price) - : t("packages.card.onRequest"), - isSubscription: Boolean(active.package_limits?.subscription), - } - : null, - serverStep: active ? "package_selected" : undefined, - meta: active - ? { - packages: [active.package_id], - is_active: active.active, - } - : undefined, - }); - } - }, [packagesState, markStep, currencyFormatter, t]); - - const handleSelectPackage = React.useCallback( - (pkg: Package) => { - const priceText = - typeof pkg.price === "number" ? currencyFormatter.format(pkg.price) : t("packages.card.onRequest"); - - markStep({ - packageSelected: true, - lastStep: "packages", - selectedPackage: { - id: pkg.id, - name: pkg.name, - priceText, - isSubscription: Boolean(pkg.features?.subscription), - }, - serverStep: "package_selected", - meta: { packages: [pkg.id] }, - }); - - navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } }); - }, - [currencyFormatter, markStep, navigate, t] - ); - - const renderPackageList = () => { - if (packagesState.status === "loading") { - return ( - - - {t("packages.state.loading")} - - ); - } - - if (packagesState.status === "error") { - return ( - - - {t("packages.state.errorTitle")} - {packagesState.message ?? t("packages.state.errorDescription")} - - ); - } - - if (packagesState.status === "success" && packagesState.catalog.length === 0) { - return ( - - {t("packages.state.emptyTitle")} - {t("packages.state.emptyDescription")} - - ); - } - - if (packagesState.status !== "success") { - return null; - } - - return ( -
- {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]) => formatFeatureLabel(t, key)); - - const isSubscription = Boolean(pkg.features?.subscription); - const priceText = - typeof pkg.price === "number" ? currencyFormatter.format(pkg.price ?? 0) : t("packages.card.onRequest"); - - const badges: string[] = []; - if (pkg.max_guests) { - badges.push(t("packages.card.badges.guests", { count: pkg.max_guests })); - } - if (pkg.gallery_days) { - badges.push(t("packages.card.badges.days", { count: pkg.gallery_days })); - } - if (pkg.max_photos) { - badges.push(t("packages.card.badges.photos", { count: pkg.max_photos })); - } - - return ( - -
-
-

- {t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")} -

-

{pkg.name}

-
- {priceText} -
-

- {pkg.max_photos - ? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos }) - : t("packages.card.description")} -

-
- {badges.map((badge) => ( - - {badge} - - ))} - {featureLabels.map((feature) => ( - - {feature} - - ))} - {isActive ? ( - - {t("packages.card.active")} - - ) : null} -
-
- - {purchased ? ( -

- {t("packages.card.purchased", { - date: purchased.purchased_at - ? dateFormatter.format(new Date(purchased.purchased_at)) - : t("packages.card.purchasedUnknown"), - })} -

- ) : null} - -
-
- ); - })} -
- ); - }; - - return ( - - - {renderPackageList()} - - - navigate(ADMIN_WELCOME_SUMMARY_PATH), - icon: ArrowRight, - }, - ]} - /> - - ); -} diff --git a/resources/js/admin/pages/BillingPage.tsx b/resources/js/admin/pages/BillingPage.tsx index a86b7b3..cb364c2 100644 --- a/resources/js/admin/pages/BillingPage.tsx +++ b/resources/js/admin/pages/BillingPage.tsx @@ -19,14 +19,7 @@ import { PaginationMeta, } from '../api'; import { isAuthError } from '../auth/tokens'; -import { - TenantHeroCard, - FrostedSurface, - tenantHeroPrimaryButtonClass, - tenantHeroSecondaryButtonClass, - SectionCard, - SectionHeader, -} from '../components/tenant'; +import { FrostedSurface, SectionCard, SectionHeader } from '../components/tenant'; type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string }; @@ -177,10 +170,6 @@ export default function BillingPage() { void loadAll(); }, [loadAll]); - const activeWarnings = React.useMemo( - () => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'), - [activePackage, t, formatDate], - ); const hasMoreAddons = React.useMemo(() => { if (!addonMeta) { return false; @@ -188,107 +177,63 @@ export default function BillingPage() { return addonMeta.current_page < addonMeta.last_page; }, [addonMeta]); - const heroBadge = t('billing.hero.badge', 'Abrechnung'); - const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.'); - const heroSupporting: string[] = [ - activePackage - ? t('billing.hero.summary.active', 'Aktives Paket: {{name}}', { name: activePackage.package_name }) - : t('billing.hero.summary.inactive', 'Noch kein aktives Paket – wählt ein Kontingent, das zu euch passt.'), - t('billing.hero.summary.transactions', '{{count}} Zahlungen synchronisiert', { count: transactions.length }) - ]; - const packagesHref = `/${i18n.language?.split('-')[0] ?? 'de'}/packages`; - const heroPrimaryAction = ( - + const computedRemainingEvents = React.useMemo(() => { + if (!activePackage) { + return null; + } + + const used = activePackage.used_events ?? 0; + if (activePackage.remaining_events !== null && activePackage.remaining_events !== undefined) { + return activePackage.remaining_events; + } + + const allowance = activePackage.package_limits?.max_events_per_year ?? 1; + return Math.max(0, allowance - used); + }, [activePackage]); + + const normalizedActivePackage = React.useMemo(() => { + if (!activePackage) return null; + return { + ...activePackage, + remaining_events: computedRemainingEvents ?? activePackage.remaining_events, + }; + }, [activePackage, computedRemainingEvents]); + + const topWarning = React.useMemo(() => { + const warnings = buildPackageWarnings( + normalizedActivePackage, + (key, options) => t(key, options), + formatDate, + 'billing.sections.overview.warnings', + ); + + return warnings[0]; + }, [formatDate, normalizedActivePackage, t]); + + const activeWarnings = React.useMemo( + () => buildPackageWarnings(normalizedActivePackage, t, formatDate, 'billing.sections.overview.warnings'), + [normalizedActivePackage, t, formatDate], ); - const heroSecondaryAction = ( - - ); - const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am'); - const topWarning = activeWarnings[0]; - const billingStats = React.useMemo( - () => [ - { - key: 'package', - label: t('billing.stats.package.label', 'Aktives Paket'), - value: activePackage?.package_name ?? t('billing.stats.package.empty', 'Keines'), - helper: activePackage?.expires_at - ? t('billing.stats.package.helper', { date: formatDate(activePackage.expires_at) }) - : t('billing.stats.package.helper', { date: '—' }), - tone: 'pink' as const, - }, + const billingStats = React.useMemo(() => { + if (!activePackage) { + return [] as const; + } + + const used = activePackage.used_events ?? 0; + const remaining = computedRemainingEvents ?? 0; + + return [ { key: 'events', label: t('billing.stats.events.label', 'Genutzte Events'), - value: activePackage?.used_events ?? 0, - helper: t('billing.stats.events.helper', { count: activePackage?.remaining_events ?? 0 }), + value: used, + helper: t('billing.stats.events.helper', { count: remaining }), tone: 'amber' as const, }, - { - key: 'addons', - label: t('billing.stats.addons.label', 'Add-ons'), - value: addonHistory.length, - helper: t('billing.stats.addons.helper', 'Historie insgesamt'), - tone: 'sky' as const, - }, - { - key: 'transactions', - label: t('billing.stats.transactions.label', 'Transaktionen'), - value: transactions.length, - helper: t('billing.stats.transactions.helper', 'Synchronisierte Zahlungen'), - tone: 'emerald' as const, - }, - ], - [activePackage, addonHistory.length, transactions.length, formatDate, t] - ); - const heroAside = ( - -
-

- {t('billing.hero.activePackage', 'Aktuelles Paket')} -

-

- {activePackage?.package_name ?? t('billing.hero.activeFallback', 'Noch nicht ausgewählt')} -

-
-
-

{nextRenewalLabel}

-

{formatDate(activePackage?.expires_at)}

-
- {topWarning ? ( -
- {topWarning.message} -
- ) : null} -
- ); - + ]; + }, [activePackage, computedRemainingEvents, t]); return ( - - {error && ( {t('dashboard:alerts.errorTitle')} @@ -300,7 +245,6 @@ export default function BillingPage() { ) : ( <> - - + 1; + + if (remaining !== null && shouldWarn) { if (remaining <= 0) { warnings.push({ id: `${pkg.id}-no-events`, diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index 486bd97..bea75e4 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -201,10 +201,6 @@ export default function DashboardPage() { if (loading) { return; } - if (!progress.eventCreated && events.length === 0 && !location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH)) { - navigate(ADMIN_WELCOME_BASE_PATH, { replace: true }); - return; - } if (events.length > 0 && !progress.eventCreated) { const primary = events[0]; markStep({ @@ -517,17 +513,6 @@ export default function DashboardPage() { [translate, navigate, hasEventContext], ); - const dashboardTabs = React.useMemo( - () => [ - { key: 'overview', label: translate('tabs.overview', 'Überblick'), href: `${ADMIN_HOME_PATH}#overview` }, - { key: 'live', label: translate('tabs.live', 'Live'), href: `${ADMIN_HOME_PATH}#live` }, - { key: 'setup', label: translate('tabs.setup', 'Vorbereitung'), href: `${ADMIN_HOME_PATH}#setup` }, - { key: 'recap', label: translate('tabs.recap', 'Nachbereitung'), href: `${ADMIN_HOME_PATH}#recap` }, - ], - [translate] - ); - const currentDashboardTab = React.useMemo(() => (location.hash?.replace('#', '') || 'overview'), [location.hash]); - const adminTitle = singleEventName ?? greetingTitle; const adminSubtitle = singleEvent ? translate('overview.eventHero.subtitle', { @@ -579,7 +564,7 @@ export default function DashboardPage() { ); return ( - + {errorMessage && ( {t('dashboard.alerts.errorTitle')} diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index a3989f7..668be1e 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { - AlertTriangle, ArrowLeft, Bell, Camera, @@ -15,10 +14,10 @@ import { Printer, QrCode, PlugZap, - RefreshCw, Smile, Sparkles, ShoppingCart, + Menu, Users, } from 'lucide-react'; import toast from 'react-hot-toast'; @@ -61,21 +60,13 @@ import { import { SectionCard, SectionHeader, - ActionGrid, - TenantHeroCard, } from '../components/tenant'; import { AddonsPicker } from '../components/Addons/AddonsPicker'; import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; import { EventAddonCatalogItem, getAddonCatalog } from '../api'; import { GuestBroadcastCard } from '../components/GuestBroadcastCard'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { filterEmotionsByEventType } from '../lib/emotions'; -import { buildEventTabs } from '../lib/eventTabs'; - -type EventDetailPageProps = { - mode?: 'detail' | 'toolkit'; -}; - type ToolkitState = { data: EventToolkit | null; loading: boolean; @@ -90,7 +81,7 @@ type WorkspaceState = { error: string | null; }; -export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) { +export default function EventDetailPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -214,9 +205,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp const toolkitData = toolkit.data; const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); - const subtitle = mode === 'toolkit' - ? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.') - : t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.'); + const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.'); const limitWarnings = React.useMemo( () => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []), @@ -266,23 +255,6 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp [event?.slug], ); - const eventTabs = React.useMemo(() => { - if (!event) { - return []; - } - const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - return buildEventTabs(event, translateMenu, { - photos: toolkitData?.photos?.pending?.length ?? event.photo_count ?? 0, - tasks: toolkitData?.tasks?.summary.total ?? event.tasks_count ?? 0, - invites: toolkitData?.invites?.summary.active ?? event.active_invites_count ?? event.total_invites_count ?? 0, - }); - }, [event, toolkitData?.photos?.pending?.length, toolkitData?.tasks?.summary.total, toolkitData?.invites?.summary.active, t]); - - const isRecapRoute = React.useMemo( - () => location.pathname.endsWith('/recap'), - [location.pathname], - ); - const shownWarningToasts = React.useRef>(new Set()); //const [addonBusyId, setAddonBusyId] = React.useState(null); @@ -379,8 +351,6 @@ const shownWarningToasts = React.useRef>(new Set()); {error && ( @@ -455,70 +425,71 @@ const shownWarningToasts = React.useRef>(new Set()); ) : event ? (
- { void load(); }} - loading={state.busy} - navigate={navigate} - /> - - - - {t('events.workspace.tabs.overview', 'Überblick')} - {t('events.workspace.tabs.setup', 'Vorbereitung')} - {t('events.workspace.tabs.recap', 'Nachbereitung')} - - - -
- - +
+
+
+

+ {t('events.workspace.hero.badge', 'Event')} +

+

{resolveName(event.name)}

+

+ {t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')} +

- - +
+ + + +
+
- -
- navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} /> - navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} + +
+ navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} /> + navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} + /> +
+ navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} + onOpenCollections={() => navigate(buildEngagementTabPath('collections'))} + onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} + onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))} + /> + {event.addons?.length ? ( + + -
- - navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} - onOpenCollections={() => navigate(buildEngagementTabPath('collections'))} - onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} - onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))} - /> - - {event.addons?.length ? ( - - - t(key, fallback)} /> - - ) : null} -
- - - navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} - /> - {event.limits?.gallery ? ( - - ) : null} - - - + t(key, fallback)} /> + + ) : null} + navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} + /> + {event.limits?.gallery ? : null} + + +
) : ( @@ -543,78 +514,6 @@ function resolveName(name: TenantEvent['name']): string { return 'Event'; } -function EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: { - event: TenantEvent; - stats: EventStats | null; - onRefresh: () => void; - loading: boolean; - navigate: ReturnType; -}) { - const { t } = useTranslation('management'); - const statusLabel = getStatusLabel(event, t); - const supporting = [ - t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }), - t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }), - t('events.workspace.hero.metrics', { - defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}', - count: stats?.uploads_total ?? stats?.total ?? 0, - likes: stats?.likes_total ?? stats?.likes ?? 0, - }), - ]; - - const aside = ( -
- } - label={t('events.workspace.fields.status', 'Status')} - value={statusLabel} - /> - } - label={t('events.workspace.fields.date', 'Eventdatum')} - value={formatDate(event.event_date)} - /> - } - label={t('events.workspace.fields.active', 'Aktiv für Gäste')} - value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')} - /> -
- ); - - return ( - navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50"> - {t('events.actions.backToList', 'Zurück zur Liste')} - - )} - secondaryAction={( - - )} - aside={aside} - > -
- -
-
- ); -} - function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) { const { t } = useTranslation('management'); @@ -656,125 +555,53 @@ function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stat
)} -
- -
); } -function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise; navigate: ReturnType }) { +function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType }) { const { t } = useTranslation('management'); - const gridItems = [ - { - key: 'photos', - icon: , - label: t('events.quickActions.moderate', 'Fotos moderieren'), - description: t('events.quickActions.moderateDesc', 'Prüfe Uploads und veröffentliche Highlights.'), - onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)), - }, - { - key: 'tasks', - icon: , - label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'), - description: t('events.quickActions.tasksDesc', 'Passe Story-Prompts und Moderation an.'), - onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)), - }, - { - key: 'invites', - icon: , - label: t('events.quickActions.invites', 'Layouts & QR verwalten'), - description: t('events.quickActions.invitesDesc', 'Aktualisiere QR-Kits und Einladungslayouts.'), - onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`), - }, - { - key: 'roles', - icon: , - label: t('events.quickActions.roles', 'Team & Rollen anpassen'), - description: t('events.quickActions.rolesDesc', 'Verwalte Moderatoren und Co-Leads.'), - onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)), - }, - { - key: 'photobooth', - icon: , - label: t('events.quickActions.photobooth', 'Photobooth anbinden'), - description: t('events.quickActions.photoboothDesc', 'FTP-Link aktivieren und Zugangsdaten kopieren.'), - onClick: () => navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug)), - }, - { - key: 'print', - icon: , - label: t('events.quickActions.print', 'Layouts als PDF drucken'), - description: t('events.quickActions.printDesc', 'Exportiere QR-Sets für den Druck.'), - onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`), - }, + const actions = [ + { key: 'photos', icon: , label: t('events.quickActions.moderate', 'Fotos moderieren'), onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)) }, + { key: 'tasks', icon: , label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'), onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)) }, + { key: 'invites', icon: , label: t('events.quickActions.invites', 'Layouts & QR verwalten'), onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`) }, + { key: 'roles', icon: , label: t('events.quickActions.roles', 'Team & Rollen anpassen'), onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)) }, + { key: 'photobooth', icon: , label: t('events.quickActions.photobooth', 'Photobooth anbinden'), onClick: () => navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug)) }, + { key: 'print', icon: , label: t('events.quickActions.print', 'Layouts als PDF drucken'), onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`) }, ]; return ( - - - -
- -
-
- ); -} - -function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | null }) { - const { t } = useTranslation('management'); - - const cards = [ - { - icon: , - label: t('events.metrics.uploadsTotal', 'Uploads gesamt'), - value: metrics?.uploads_total ?? stats?.uploads_total ?? 0, - }, - { - icon: , - label: t('events.metrics.uploads24h', 'Uploads (24h)'), - value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0, - }, - { - icon: , - label: t('events.metrics.pending', 'Fotos in Moderation'), - value: metrics?.pending_photos ?? stats?.pending_photos ?? 0, - }, - { - icon: , - label: t('events.metrics.activeInvites', 'Aktive Einladungen'), - value: metrics?.active_invites ?? 0, - }, - ]; - - return ( -
- {cards.map((card) => ( - -
- - {card.icon} - -
-

{card.label}

-

{card.value}

-
-
-
- ))} -
+ + + + {t('events.quickActions.title', 'Starte in die Moderation')} + +
+ {actions.map((action) => ( + + ))} +
+
+ ); } diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 1be2b95..77e8b03 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { AdminLayout } from '../components/AdminLayout'; import { @@ -96,6 +97,16 @@ export default function EventFormPage() { queryKey: ['tenant', 'event-types'], queryFn: getEventTypes, }); + const sortedEventTypes = React.useMemo(() => { + if (!eventTypes) { + return []; + } + return [...eventTypes].sort((a, b) => { + const aName = (a.name as string) ?? ''; + const bName = (b.name as string) ?? ''; + return aName.localeCompare(bName, undefined, { sensitivity: 'base' }); + }); + }, [eventTypes]); const { data: packageOverview, isLoading: overviewLoading } = useQuery({ queryKey: ['tenant', 'packages', 'overview'], @@ -400,9 +411,20 @@ export default function EventFormPage() { return []; } - return Object.entries(selectedPackage.features) - .filter(([, enabled]) => Boolean(enabled)) - .map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' ')); + const normalizeLabel = (key: string) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '); + + const raw = selectedPackage.features as unknown; + if (Array.isArray(raw)) { + return raw.filter(Boolean).map((key) => normalizeLabel(String(key))); + } + + if (typeof raw === 'object' && raw !== null) { + return Object.entries(raw) + .filter(([, enabled]) => Boolean(enabled)) + .map(([key]) => normalizeLabel(key)); + } + + return []; }, [selectedPackage]); const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null); @@ -507,7 +529,7 @@ export default function EventFormPage() { - {!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? ( + {!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (

Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.

) : null}
-
- - -
- - {isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'} - - {packagePriceLabel ? ( - - {packagePriceLabel} - - ) : null} -
-
- - {packageNameDisplay} - - - Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen. - -
- {packageExpiresLabel ? ( -

- Galerie aktiv bis {packageExpiresLabel} -

- ) : null} - {remainingEventsLabel ? ( -

{remainingEventsLabel}

- ) : null} -
- - {packageHighlights.length ? ( -
- {packageHighlights.map((highlight) => ( -
-

{highlight.label}

-

{highlight.value}

-
- ))} -
- ) : null} - {featureTags.length ? ( -
- {featureTags.map((feature) => ( - - {feature} - - ))} -
- ) : ( -

- {(packagesLoading || overviewLoading) - ? 'Paketdetails werden geladen...' - : 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'} -

- )} -
- - -
-
-
-
@@ -643,6 +588,92 @@ export default function EventFormPage() { Abbrechen
+
+ + + +
+ + {isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'} + + {packageNameDisplay} + {packagePriceLabel ? ( + + {packagePriceLabel} + + ) : null} +
+
+ + + +
+ + {packageNameDisplay} + + + Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen. + +
+ {packageExpiresLabel ? ( +

+ Galerie aktiv bis {packageExpiresLabel} +

+ ) : null} + {remainingEventsLabel ? ( +

{remainingEventsLabel}

+ ) : null} +
+ + {packageHighlights.length ? ( +
+ {packageHighlights.map((highlight) => ( +
+

{highlight.label}

+

{highlight.value}

+
+ ))} +
+ ) : null} + {featureTags.length ? ( +
+ {featureTags.map((feature) => ( + + {feature} + + ))} +
+ ) : ( +

+ {(packagesLoading || overviewLoading) + ? 'Paketdetails werden geladen...' + : 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'} +

+ )} +
+ + +
+
+
+
+
+
+
)} diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index 36b222a..61ee391 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -30,7 +30,6 @@ import { isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH, - ADMIN_EVENT_TOOLKIT_PATH, ADMIN_EVENT_PHOTOS_PATH, } from '../constants'; import { buildLimitWarnings } from '../lib/limitWarnings'; @@ -817,7 +816,7 @@ export default function EventInvitesPage(): React.ReactElement { - ); - const heroSecondaryAction = ( - - ); - const heroAside = ( - -
-
-

- {t('events.list.hero.published_label', 'Veröffentlichte Events')} -

-

{publishedEvents}

-

- {t('events.list.hero.total_label', ':count insgesamt', { count: totalEvents })} -

-
- {nextEvent ? ( -
-

- {t('events.list.hero.next_label', 'Nächstes Event')} -

-

{renderName(nextEvent.name)}

-

{formatDate(nextEvent.event_date)}

- {nextEvent.slug ? ( - - ) : null} -
- ) : ( -

- {t('events.list.hero.no_upcoming', 'Plane ein Datum, um hier die nächste Station zu sehen.')} -

- )} -
-
- ); const draftEvents = totalEvents - publishedEvents; const [statusFilter, setStatusFilter] = React.useState<'all' | 'published' | 'draft'>('all'); const filteredRows = React.useMemo(() => { @@ -225,21 +78,6 @@ export default function EventsPage() { } return rows; }, [rows, statusFilter]); - const overviewDescription = React.useMemo(() => { - if (loading) { - return t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …'); - } - if (filteredRows.length === 0) { - if (statusFilter === 'published') { - return t('events.list.overview.empty_published', 'Noch keine veröffentlichten Events.'); - } - if (statusFilter === 'draft') { - return t('events.list.overview.empty_drafts', 'Keine Entwürfe – nutze die Zeit für dein nächstes Event.'); - } - return t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.'); - } - return t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: filteredRows.length }); - }, [filteredRows.length, loading, statusFilter, t]); const filterOptions: Array<{ key: 'all' | 'published' | 'draft'; label: string; count: number }> = [ { key: 'all', label: t('events.list.filters.all', 'Alle'), count: totalEvents }, { key: 'published', label: t('events.list.filters.published', 'Live'), count: publishedEvents }, @@ -247,7 +85,7 @@ export default function EventsPage() { ]; return ( - + {error && ( Fehler beim Laden @@ -255,32 +93,7 @@ export default function EventsPage() { )} - - - - {t('events.list.badge.dashboard', 'Tenant Dashboard')} - - )} - /> - -
{filterOptions.map((option) => ( + + +
+
+

{t('welcome.theme.label', 'Darstellung')}

+

{themeLabel}

+
+ +
+
+ {t('app.languageSwitch')} + +
+
); - const toggleMode = React.useCallback(() => { - setMode((prev) => (prev === 'dark' ? 'light' : 'dark')); - }, []); - - const handleLoginRedirect = React.useCallback(() => { - if (isRedirecting) { - return; - } - - setIsRedirecting(true); - - const params = new URLSearchParams(window.location.search); - const rawReturnTo = params.get('return_to'); - const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH); - const target = new URL(ADMIN_LOGIN_PATH, window.location.origin); - target.searchParams.set('return_to', encodedFinal ?? encodeReturnTo(finalTarget)); - const nextHref = `${target.pathname}${target.search}`; - navigateToHref(nextHref); - }, [isRedirecting]); - - const handleScrollToFlow = React.useCallback(() => { - flowSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, []); - return ( -
+
-
+
-
-
-
- - Event Admin · Fotospiel - -

- Euer mobiles Kontrollzentrum für Events, Workshops und Feiern -

+
+
+
+
+ FS +
+
+

+ {t('welcome.eyebrow', 'Event Admin')} +

+

Fotospiel.app

+
-
+ +
+ - -
+ +
+ + + + + + + {t('welcome.menu.title', 'Navigation')} + + {renderMenuActions()} + + +
-
-
-
+
+
+
+
+ + + {t('welcome.badge', 'Fotos, Aufgaben & Einladungen an einem Ort')} + +
+ + {t('welcome.loginPrompt', 'Bereits Kunde? Login oben rechts.')} +
+
+
-

- Willkommen im Event-Kontrollzentrum -

-

- Alles für eure Gästegeschichte in einer App +

+ {t('welcome.title', 'Event-Branding, Aufgaben & Foto-Moderation in einer App.')}

-

- Der neue Startscreen begrüßt euch wie eine mobile App, führt euch in den Welcome Flow und lässt euch - jederzeit zurück ins Dashboard springen, sobald ihr bereit seid. +

+ {t('welcome.subtitle', 'Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.')}

-
- - +
-
- {heroStats.map((stat) => ( - -

{stat.value}

-

- {stat.label} -

-
- ))} -
-
-
-
- Fotospiel - Event Admin +
+
+
+ + {t('welcome.preview.title', 'Was dich erwartet')}
-
-
- Heute - Live -
-
- {['Mission Pack auswählen', 'Event Branding finalisieren', 'Einladungslink teilen'].map((item) => ( -
-
-

{item}

-

Welcome Flow

-
- -
- ))} -
+
+ {previewBullets.map((item) => ( +
+ +

{item}

+
+ ))}
-
-
- Live Moments - +28 +
+
+

{t('welcome.highlight.moderation', 'Live-Moderation')}

+

{t('welcome.highlight.moderationHint', 'Approve/Hide, Highlights, Galerie-Link')}

-
- {[ - { title: 'Anna lädt 6 Fotos hoch', subtitle: 'Event · Gartenpalast' }, - { title: 'Max reagiert auf Mission „Emotionen“', subtitle: 'Tasks · Emotion Board' }, - { title: 'QR-Link 124x gescannt', subtitle: 'Einladungen · Photobooth' }, - ].map((entry) => ( -
-

{entry.title}

-

{entry.subtitle}

-
- ))} +
+

{t('welcome.highlight.tasks', 'Aufgaben & Emotion-Sets')}

+

{t('welcome.highlight.tasksHint', 'Sammlungen importieren oder eigene erstellen')}

-
- {featureCards.map((feature) => ( - +
+
+

{t('welcome.features.title', 'Was du steuern kannst')}

+

{t('welcome.features.subtitle', 'Alles an einem Ort')}

+
+
+ {features.map((feature) => (
- - {feature.badge} -
-

- {feature.title} -

-

- {feature.description} -

- - ))} -
- -
- -

- Guided Journey -

-

- So leitet euch der Welcome Flow -

-
- {timelineSteps.map((step, index) => ( -
-
- {index + 1} -
-
-

- {step.title} -

-

{step.description}

-
+
+ + {feature.title}
- ))} -
- - -
- -
- Mobile Greeting - Neu +

{feature.description}

-

Fühlt sich an wie eine native App

-

- Mit großen Touch-Flächen, Animationen und frosted Cards wirkt der Startscreen wie die mobile Version - des Event Admins – perfekt für TWA und Capacitor Builds. -

-
-
-

CTA

-

Event Admin öffnen

-

Leitet direkt zum OAuth Login

-
-
-

Scroll Action

-

Journey ansehen

-

Smooth Scroll bis zur Timeline

-
-
-
- -
- {supportHighlights.map((support) => ( - - -

- {support.title} -

-

{support.description}

-
- ))} -
+ ))}
- -
-

Bereit?

-

- Wechselt ins Dashboard, sobald euer Flow steht. -

-

- Alle Schritte lassen sich später direkt aus dem Event Admin wieder öffnen. -

+
+
+

{t('welcome.steps.title', "So funktioniert's")}

+

{t('welcome.steps.subtitle', 'In drei Schritten bereit')}

- - +
+ {steps.map((step) => ( +
+
+ {step.accent} +
+

{step.title}

+

{step.description}

+
+ ))} +
+
+ +
+
+

{t('welcome.plans.title', 'Pakete im Überblick')}

+

{t('welcome.plans.subtitle', 'Wähle das passende Kontingent')}

+

{t('welcome.plans.hint', 'Starter, Standard oder Reseller – alles mit Moderation & Einladungen.')}

+
+
+ {plans.map((plan) => ( +
+
+ + {plan.badge ?? t('welcome.plans.badge', 'Paket')} + + {plan.highlight ? ( + + {plan.highlight} + + ) : null} +
+
+ + {plan.title} +
+
    + {plan.points.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ +
+
+ ))} +
+
+ +
+
+

{t('welcome.audience.title', 'Für wen?')}

+

{t('welcome.audience.subtitle', 'Endkunden & Reseller im Blick')}

+
+
+ {audienceCards.map((audience) => ( +
+
+ + {audience.title} +
+

{audience.description}

+
+ + {t('welcome.audience.cta', 'Wenige Klicks bis zum Start')} +
+
+ ))} +
+
+ +
+
+
+

{t('welcome.footer.eyebrow', 'Bereit?')}

+

{t('welcome.footer.title', 'Melde dich an oder prüfe die Pakete')}

+

{t('welcome.footer.subtitle', 'Login für bestehende Kunden, Pakete für neue Teams.')}

+
+
+ + +
+
+
- -
- Fotospiel · Eure Gäste gestalten eure Lieblingsmomente -
); diff --git a/resources/js/admin/pages/__tests__/WelcomeTeaserPage.test.tsx b/resources/js/admin/pages/__tests__/WelcomeTeaserPage.test.tsx index 5a2da08..85c4d7c 100644 --- a/resources/js/admin/pages/__tests__/WelcomeTeaserPage.test.tsx +++ b/resources/js/admin/pages/__tests__/WelcomeTeaserPage.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { describe, expect, it, afterEach, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../i18n'; import WelcomeTeaserPage from '../WelcomeTeaserPage'; const navigateMock = vi.fn(); @@ -14,7 +16,27 @@ vi.mock('../../lib/navigation', () => ({ navigateToHref: (href: string) => navigateMock(href), })); +vi.mock('../../auth/context', () => ({ + useAuth: () => ({ status: 'unauthenticated' }), +})); + +vi.mock('@/hooks/use-appearance', async () => { + const ReactImport = await import('react'); + return { + useAppearance: () => { + const [appearance, setAppearance] = ReactImport.useState<'light' | 'dark'>('light'); + return { appearance, updateAppearance: setAppearance }; + }, + }; +}); + describe('WelcomeTeaserPage', () => { + const renderWithI18n = () => render( + + + + ); + afterEach(() => { document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme'); vi.clearAllMocks(); @@ -22,7 +44,7 @@ describe('WelcomeTeaserPage', () => { }); it('applies the tenant admin theme classes while mounted', () => { - const { unmount } = render(); + const { unmount } = renderWithI18n(); expect(document.body.classList.contains('tenant-admin-theme')).toBe(true); expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(true); @@ -33,25 +55,25 @@ describe('WelcomeTeaserPage', () => { expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(false); }); - it('shows the hero stats and triggers the login redirect CTA', async () => { - render(); - - expect(screen.getByText('Events begleitet')).toBeInTheDocument(); - + it('shows the hero CTA and triggers the login redirect', async () => { + renderWithI18n(); const user = userEvent.setup(); - await user.click(screen.getByRole('button', { name: /event admin öffnen/i })); + const loginButton = screen.getAllByRole('button', { name: /login/i })[0]; + await user.click(loginButton); expect(navigateMock).toHaveBeenCalledWith(expect.stringContaining('/event-admin/login')); }); it('allows switching between light and dark presentation modes', async () => { - render(); + renderWithI18n(); const user = userEvent.setup(); - const toggle = screen.getByRole('button', { name: /light mode/i }); + const toggle = screen.getByLabelText(/welcome\.theme\.aria|darstellung|appearance/i); + + expect(toggle).toHaveTextContent(/hell|light/i); await user.click(toggle); - expect(toggle).toHaveTextContent(/dark mode/i); + expect(toggle).toHaveTextContent(/dunkel|dark/i); }); }); diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 0ba0503..b8dad1f 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -32,10 +32,6 @@ const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage')); const ProfilePage = React.lazy(() => import('./pages/ProfilePage')); const LogoutPage = React.lazy(() => import('./pages/LogoutPage')); const LiveRedirectPage = React.lazy(() => import('./pages/LiveRedirectPage')); -const WelcomeLandingPage = React.lazy(() => import('./onboarding/pages/WelcomeLandingPage')); -const WelcomePackagesPage = React.lazy(() => import('./onboarding/pages/WelcomePackagesPage')); -const WelcomeEventSetupPage = React.lazy(() => import('./onboarding/pages/WelcomeEventSetupPage')); -const WelcomeOrderSummaryPage = React.lazy(() => import('./onboarding/pages/WelcomeOrderSummaryPage')); function RequireAuth() { const { status } = useAuth(); @@ -119,10 +115,6 @@ export const router = createBrowserRouter([ { path: 'settings', element: }, { path: 'faq', element: }, { path: 'settings/profile', element: }, - { path: 'welcome', element: }, - { path: 'welcome/packages', element: }, - { path: 'welcome/summary', element: }, - { path: 'welcome/event', element: }, ], }, ],