Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-15 17:33:36 +01:00
parent 2f93271d94
commit ad829ae509
50 changed files with 1335 additions and 411 deletions

View File

@@ -433,6 +433,8 @@ export type TenantPackageSummary = {
id: number;
package_id: number;
package_name: string;
package_type: string | null;
included_package_slug: string | null;
active: boolean;
used_events: number;
remaining_events: number | null;
@@ -743,6 +745,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived';
is_active?: boolean;
package_id?: number;
service_package_slug?: string;
accepted_waiver?: boolean;
settings?: Record<string, unknown> & {
live_show?: LiveShowSettings;
@@ -1008,6 +1011,18 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
id: Number(pkg.id ?? 0),
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),
package_type:
typeof (packageData as any).type === 'string'
? String((packageData as any).type)
: typeof (pkg as any).package_type === 'string'
? String((pkg as any).package_type)
: null,
included_package_slug:
typeof (packageData as any).included_package_slug === 'string'
? String((packageData as any).included_package_slug)
: typeof (pkg as any).included_package_slug === 'string'
? String((pkg as any).included_package_slug)
: null,
active: Boolean(pkg.active ?? false),
used_events: Number(pkg.used_events ?? 0),
remaining_events: pkg.remaining_events !== undefined ? Number(pkg.remaining_events) : null,
@@ -2099,11 +2114,19 @@ export async function submitTenantFeedback(payload: {
export type Package = {
id: number;
name: string;
slug?: string;
type?: 'endcustomer' | 'reseller';
price: number;
max_photos: number | null;
max_guests: number | null;
gallery_days: number | null;
features: Record<string, boolean>;
max_events_per_year?: number | null;
included_package_slug?: string | null;
paddle_price_id?: string | null;
paddle_product_id?: string | null;
branding_allowed?: boolean | null;
watermark_allowed?: boolean | null;
features: string[] | Record<string, boolean> | null;
};
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {

View File

@@ -2,8 +2,8 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
const CREDENTIALS: Record<string, { login: string; password: string }> = {
'cust-standard-empty': { login: 'standard-empty@demo.fotospiel', password: 'Demo1234!' },
'cust-starter-wedding': { login: 'starter-wedding@demo.fotospiel', password: 'Demo1234!' },
'reseller-s-active': { login: 'reseller-active@demo.fotospiel', password: 'Demo1234!' },
'reseller-s-full': { login: 'reseller-full@demo.fotospiel', password: 'Demo1234!' },
'reseller-s-active': { login: 'partner-active@demo.fotospiel', password: 'Demo1234!' },
'reseller-s-full': { login: 'partner-full@demo.fotospiel', password: 'Demo1234!' },
};
async function loginAs(key: string): Promise<void> {

View File

@@ -74,8 +74,8 @@
},
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.",
"eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.",
"eventLimit": "Dein aktuelles Paket enthält kein freies Event-Kontingent mehr.",
"eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben im Kontingent.",
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
"goToBilling": "Zur Paketverwaltung"
},
@@ -174,7 +174,7 @@
"plans": {
"title": "Pakete im Überblick",
"subtitle": "Wähle das passende Kontingent",
"hint": "Starter, Standard oder Reseller alles mit Moderation & QR-Codes.",
"hint": "Starter, Standard oder Partner alles mit Moderation & QR-Codes.",
"starter": {
"title": "Starter",
"badge": "Für ein Event",
@@ -191,23 +191,23 @@
"p3": "Support bei Live-Events"
},
"reseller": {
"title": "Reseller S",
"badge": "Für Dienstleister",
"title": "Partner Start",
"badge": "Für Agenturen",
"highlight": "Mehrere Events parallel verwalten",
"p1": "Bis zu 5 Events pro Paket",
"p1": "Bis zu 5 Events pro Kontingent",
"p2": "Aufgaben-Sammlungen und Vorlagen",
"p3": "Teamrollen & Rechteverwaltung"
}
},
"audience": {
"title": "Für wen?",
"subtitle": "Endkunden & Reseller im Blick",
"subtitle": "Endkunden & Partner im Blick",
"endcustomers": {
"title": "Endkund:innen",
"description": "Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen."
},
"resellers": {
"title": "Reseller & Agenturen",
"title": "Partner / Agenturen",
"description": "Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen."
},
"cta": "Wenige Klicks bis zum Start"

View File

@@ -32,8 +32,8 @@
"publishedHint": "{{count}} veröffentlicht",
"newPhotos": "Neue Fotos (7 Tage)",
"taskProgress": "Task-Fortschritt",
"credits": "Event-Slots",
"lowCredits": "Mehr Slots buchen empfohlen"
"credits": "Event-Kontingent",
"lowCredits": "Mehr Kontingent buchen empfohlen"
}
},
"liveNow": {
@@ -238,8 +238,8 @@
"publishedHint": "{{count}} veröffentlicht",
"newPhotos": "Neue Fotos (7 Tage)",
"taskProgress": "Task-Fortschritt",
"credits": "Event-Slots",
"lowCredits": "Mehr Slots buchen empfohlen"
"credits": "Event-Kontingent",
"lowCredits": "Mehr Kontingent buchen empfohlen"
}
},
"quickActions": {

View File

@@ -90,7 +90,7 @@
},
"warnings": {
"noEvents": "Event-Kontingent aufgebraucht. Bitte Paket upgraden oder erneuern.",
"lowEvents": "Nur noch {{remaining}} Event-Slots verfügbar.",
"lowEvents": "Nur noch {{remaining}} Events im Kontingent verfügbar.",
"expiresSoon": "Paket läuft am {{date}} ab.",
"expired": "Paket ist abgelaufen."
}
@@ -108,7 +108,7 @@
"expires": "Läuft ab",
"warnings": {
"noEvents": "Event-Kontingent aufgebraucht.",
"lowEvents": "Nur noch {{remaining}} Events verbleiben.",
"lowEvents": "Nur noch {{remaining}} Events im Kontingent verbleiben.",
"expiresSoon": "Läuft am {{date}} ab.",
"expired": "Paket ist abgelaufen."
}
@@ -1558,12 +1558,12 @@
"title": "Benachrichtigungsübersicht",
"channel": "E-Mail Kanal",
"channelCopy": "Alle Warnungen werden per E-Mail versendet.",
"credits": "Credits",
"threshold": "Warnung bei {{count}} verbleibenden Slots"
"credits": "Event-Kontingent",
"threshold": "Warnung bei {{count}} verbleibenden Events"
},
"meta": {
"creditLast": "Letzte Slot-Warnung: {{date}}",
"creditNever": "Noch keine Slot-Warnung versendet."
"creditLast": "Letzte Kontingent-Warnung: {{date}}",
"creditNever": "Noch keine Kontingent-Warnung versendet."
},
"items": {
"photoThresholds": {
@@ -1592,7 +1592,7 @@
},
"eventThresholds": {
"label": "Warnung bei Event-Kontingent",
"description": "Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist."
"description": "Hinweis, wenn das Partner / Agentur-Paket fast ausgeschöpft ist."
},
"eventLimits": {
"label": "Sperre bei Event-Kontingent",
@@ -2192,7 +2192,7 @@
"featuresTitle": "Enthaltene Features",
"feature": {
"priority_support": "Priority Support",
"reseller_dashboard": "Reseller-Dashboard",
"reseller_dashboard": "Partner-Dashboard",
"custom_domain": "Eigene Domain",
"custom_branding": "Benutzerdefiniertes Branding",
"custom_tasks": "Individuelle Aufgaben",
@@ -2907,7 +2907,7 @@
"max_guests": "Gäste",
"max_tasks": "Aufgaben",
"gallery_days": "Galerietage",
"max_events_per_year": "Events pro Jahr"
"max_events_per_year": "Event-Kontingent"
},
"mobileEvents": {
"edit": "Event bearbeiten"
@@ -3064,6 +3064,30 @@
"shop": {
"title": "Paket upgraden",
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
"partner": {
"title": "Event-Kontingent kaufen",
"subtitle": "Kaufe Event-Kontingente, um mehrere Events mit unseren Services umzusetzen.",
"buy": "Kaufen",
"unavailable": "Nicht verfügbar",
"confirmSubtitle": "Du kaufst:",
"includedTier": "Inklusive Event-Level: {{tier}}",
"eventsIncluded": "{{count}} Events im Kontingent",
"recommendedUsage": "Empfohlen innerhalb von 24 Monaten zu nutzen.",
"tiers": {
"starter": "Starter",
"standard": "Standard",
"premium": "Premium"
},
"compare": {
"rows": {
"includedTier": "Inklusive Event-Level",
"events": "Events im Kontingent"
},
"values": {
"unknown": "—"
}
}
},
"recommendationTitle": "Empfohlen für dich",
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
"compare": {

View File

@@ -41,7 +41,7 @@
"ctaList": {
"choosePackage": {
"label": "Dein Eventpaket auswählen",
"description": "Reserviere Event-Slots oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
"description": "Reserviere Event-Kontingente oder Pakete, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
"button": "Weiter zu Paketen"
},
"createEvent": {
@@ -61,7 +61,7 @@
"steps": {
"package": {
"title": "Paket sichern",
"hint": "Event-Slots oder ein Abo brauchst du, bevor Gäste live gehen."
"hint": "Event-Kontingent oder Paket brauchst du, bevor Gäste live gehen."
},
"invite": {
"title": "Team einladen",
@@ -77,10 +77,10 @@
"layout": {
"eyebrow": "Schritt 2",
"title": "Wähle dein Eventpaket",
"subtitle": "Fotospiel bietet flexible Preismodelle: einmalige Event-Slots oder Abos, die mehrere Events abdecken."
"subtitle": "Fotospiel bietet flexible Preismodelle: einzelne Event-Pakete oder Kontingente für mehrere Events."
},
"step": {
"title": "Aktiviere die passenden Event-Slots",
"title": "Aktiviere das passende Event-Kontingent",
"description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden bezahle nur, was du brauchst."
},
"state": {
@@ -92,7 +92,7 @@
},
"card": {
"subscription": "Abo",
"creditPack": "Event-Slot-Paket",
"creditPack": "Event-Kontingent",
"description": "Sofort einsatzbereit für dein nächstes Event.",
"descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive perfekt für lebendige Reportagen.",
"active": "Aktives Paket",
@@ -151,7 +151,7 @@
},
"details": {
"subscription": "Abo",
"creditPack": "Event-Slot-Paket",
"creditPack": "Event-Kontingent",
"photos": "Bis zu {{count}} Fotos",
"galleryDays": "Galerie {{count}} Tage",
"guests": "{{count}} Gäste",
@@ -188,7 +188,7 @@
"activate": "Gratis-Paket aktivieren",
"progress": "Aktivierung läuft …",
"successTitle": "Gratis-Paket aktiviert",
"successDescription": "Deine Event-Slots wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
"successDescription": "Dein Event-Kontingent wurde hinzugefügt. Weiter geht's mit dem Event-Setup.",
"failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
},
@@ -205,12 +205,12 @@
"nextSteps": [
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
"Vor dem Go-Live Event-Slots prüfen und Gäste-Link teilen."
"Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen."
],
"cta": {
"billing": {
"label": "Abrechnung starten",
"description": "Öffnet den Billing-Bereich mit Paddle- und Slot-Optionen.",
"description": "Öffnet den Billing-Bereich mit Paddle- und Kontingent-Optionen.",
"button": "Zu Billing & Zahlung"
},
"setup": {

View File

@@ -74,8 +74,8 @@
},
"errors": {
"generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event slots.",
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.",
"eventLimit": "Your current package has no remaining event kontingent.",
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the kontingent.",
"photoLimit": "This event reached its photo upload limit.",
"goToBilling": "Manage subscription"
},
@@ -174,7 +174,7 @@
"plans": {
"title": "Packages at a glance",
"subtitle": "Choose the right quota",
"hint": "Starter, Standard or Reseller all include moderation & invites.",
"hint": "Starter, Standard or Partner all include moderation & invites.",
"starter": {
"title": "Starter",
"badge": "For one event",
@@ -191,24 +191,24 @@
"p3": "Support on live days"
},
"reseller": {
"title": "Reseller S",
"badge": "For pros",
"title": "Partner Start",
"badge": "For agencies",
"highlight": "Manage multiple events",
"p1": "Up to 5 events per package",
"p1": "Up to 5 events per kontingent",
"p2": "Task collections and templates",
"p3": "Team roles & permissions"
}
},
"audience": {
"title": "Who is it for?",
"subtitle": "Built for hosts and resellers",
"subtitle": "Built for hosts and partners",
"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."
"title": "Partner / Agencies",
"description": "Track multiple events, monitor kontingent and reuse templates."
},
"cta": "Just a few clicks to go live"
},

View File

@@ -32,8 +32,8 @@
"publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)",
"taskProgress": "Task progress",
"credits": "Event slots",
"lowCredits": "Add slots soon"
"credits": "Event kontingent",
"lowCredits": "Add kontingent soon"
}
},
"liveNow": {
@@ -238,8 +238,8 @@
"publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)",
"taskProgress": "Task progress",
"credits": "Event slots",
"lowCredits": "Add slots soon"
"credits": "Event kontingent",
"lowCredits": "Add kontingent soon"
}
},
"quickActions": {

View File

@@ -90,7 +90,7 @@
},
"warnings": {
"noEvents": "Event allowance exhausted. Please upgrade or renew your package.",
"lowEvents": "Only {{remaining}} event slots remaining.",
"lowEvents": "Only {{remaining}} events remaining in the kontingent.",
"expiresSoon": "Package expires on {{date}}.",
"expired": "Package has expired."
}
@@ -108,7 +108,7 @@
"expires": "Expires",
"warnings": {
"noEvents": "Event allowance exhausted.",
"lowEvents": "Only {{remaining}} events left.",
"lowEvents": "Only {{remaining}} events remaining in the kontingent.",
"expiresSoon": "Expires on {{date}}.",
"expired": "Package has expired."
}
@@ -1556,12 +1556,12 @@
"title": "Notification overview",
"channel": "Email channel",
"channelCopy": "All warnings are delivered via email.",
"credits": "Credits",
"threshold": "Warning at {{count}} remaining slots"
"credits": "Event kontingent",
"threshold": "Warning at {{count}} remaining events"
},
"meta": {
"creditLast": "Last slot warning: {{date}}",
"creditNever": "No slot warning sent yet."
"creditLast": "Last kontingent warning: {{date}}",
"creditNever": "No kontingent warning sent yet."
},
"items": {
"photoThresholds": {
@@ -1590,7 +1590,7 @@
},
"eventThresholds": {
"label": "Event quota warning",
"description": "Notify me when the reseller package is almost used up."
"description": "Notify me when the partner / agency package is almost used up."
},
"eventLimits": {
"label": "Event quota exhausted",
@@ -2196,7 +2196,7 @@
"featuresTitle": "Included features",
"feature": {
"priority_support": "Priority support",
"reseller_dashboard": "Reseller dashboard",
"reseller_dashboard": "Partner dashboard",
"custom_domain": "Custom domain",
"custom_branding": "Custom branding",
"custom_tasks": "Custom tasks",
@@ -2911,7 +2911,7 @@
"max_guests": "Guests",
"max_tasks": "Tasks",
"gallery_days": "Gallery days",
"max_events_per_year": "Events per year"
"max_events_per_year": "Event kontingent"
},
"mobileEvents": {
"edit": "Edit event"
@@ -3068,6 +3068,30 @@
"shop": {
"title": "Upgrade Package",
"subtitle": "Choose a package to unlock more features and limits.",
"partner": {
"title": "Buy event kontingent",
"subtitle": "Buy event kontingents to run multiple events with our services.",
"buy": "Buy",
"unavailable": "Unavailable",
"confirmSubtitle": "You're buying:",
"includedTier": "Included event tier: {{tier}}",
"eventsIncluded": "{{count}} events in kontingent",
"recommendedUsage": "Recommended to use within 24 months.",
"tiers": {
"starter": "Starter",
"standard": "Standard",
"premium": "Premium"
},
"compare": {
"rows": {
"includedTier": "Included event tier",
"events": "Events in kontingent"
},
"values": {
"unknown": "—"
}
}
},
"recommendationTitle": "Recommended for you",
"recommendationBody": "The highlighted package includes the feature you requested.",
"compare": {

View File

@@ -41,7 +41,7 @@
"ctaList": {
"choosePackage": {
"label": "Choose your package",
"description": "Reserve event slots or subscriptions to activate events instantly. Flexible options for any event size.",
"description": "Reserve event kontingent or packages to activate events instantly. Flexible options for any event size.",
"button": "Continue to packages"
},
"createEvent": {
@@ -61,7 +61,7 @@
"steps": {
"package": {
"title": "Secure your package",
"hint": "Event slots or a subscription are required before guests go live."
"hint": "Event kontingent or a package is required before guests go live."
},
"invite": {
"title": "Invite your co-hosts",
@@ -77,10 +77,10 @@
"layout": {
"eyebrow": "Step 2",
"title": "Choose your package",
"subtitle": "Fotospiel supports flexible pricing: single-use event slots or subscriptions covering multiple events."
"subtitle": "Fotospiel supports flexible pricing: single event packages or kontingent for multiple events."
},
"step": {
"title": "Activate the right plan",
"title": "Activate the right event kontingent",
"description": "Secure capacity for your next event. Upgrade at any time only pay for what you need."
},
"state": {
@@ -92,7 +92,7 @@
},
"card": {
"subscription": "Subscription",
"creditPack": "Event slot pack",
"creditPack": "Event kontingent",
"description": "Ready for your next event right away.",
"descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.",
"active": "Active package",
@@ -151,7 +151,7 @@
},
"details": {
"subscription": "Subscription",
"creditPack": "Event slot pack",
"creditPack": "Event kontingent",
"photos": "Up to {{count}} photos",
"galleryDays": "{{count}} gallery days",
"guests": "{{count}} guests",
@@ -188,7 +188,7 @@
"activate": "Activate free package",
"progress": "Activating …",
"successTitle": "Free package activated",
"successDescription": "Event slots added. Continue with the setup.",
"successDescription": "Event kontingent added. Continue with the setup.",
"failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated."
},
@@ -205,12 +205,12 @@
"nextSteps": [
"Optional: finish billing via Paddle inside the billing area.",
"Complete the event setup and configure tasks, team, and gallery.",
"Check your event slots before go-live and share your guest link."
"Check your event kontingent before go-live and share your guest link."
],
"cta": {
"billing": {
"label": "Start billing",
"description": "Opens the billing area with Paddle plan options.",
"description": "Opens the billing area with Paddle kontingent options.",
"button": "Go to billing"
},
"setup": {

View File

@@ -58,6 +58,12 @@ export default function MobileBillingPage() {
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
const back = useBackNavigation(adminPath('/mobile/profile'));
const shopLink = React.useMemo(() => {
const isPartner =
activePackage?.package_type === 'reseller' || packages.some((pkg) => pkg.package_type === 'reseller');
return isPartner ? adminPath('/mobile/billing/shop?type=reseller') : adminPath('/mobile/billing/shop');
}, [activePackage?.package_type, packages]);
const load = React.useCallback(async () => {
setLoading(true);
@@ -281,7 +287,7 @@ export default function MobileBillingPage() {
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
onPress={() => navigate(shopLink)}
fullWidth={false}
/>
<CTAButton
@@ -316,7 +322,7 @@ export default function MobileBillingPage() {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
navigate(adminPath('/mobile/billing/shop'));
navigate(shopLink);
}}
fullWidth={false}
/>
@@ -385,7 +391,7 @@ export default function MobileBillingPage() {
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive
onOpenShop={() => navigate(adminPath('/mobile/billing/shop'))}
onOpenShop={() => navigate(shopLink)}
/>
) : null}
{packages
@@ -501,6 +507,15 @@ function PackageCard({
const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
const isPartnerPackage = pkg.package_type === 'reseller';
const includedTierLabel =
pkg.included_package_slug === 'starter'
? t('shop.partner.tiers.starter', 'Starter')
: pkg.included_package_slug === 'standard'
? t('shop.partner.tiers.standard', 'Standard')
: pkg.included_package_slug === 'pro'
? t('shop.partner.tiers.premium', 'Premium')
: pkg.included_package_slug;
const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null;
const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0;
const remainingText =
@@ -520,7 +535,7 @@ function PackageCard({
const limitEntries = getPackageLimitEntries(limits, t, {
remainingEvents: pkg.remaining_events ?? null,
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
});
}, { packageType: pkg.package_type });
const featureKeys = collectPackageFeatures(pkg);
const eventUsageText = formatEventUsage(
typeof pkg.used_events === 'number' ? pkg.used_events : null,
@@ -550,8 +565,9 @@ function PackageCard({
{pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
) : null}
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
{isPartnerPackage && includedTierLabel ? <PillBadge tone="muted">{includedTierLabel}</PillBadge> : null}
{!isPartnerPackage ? renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding')) : null}
{!isPartnerPackage ? renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark')) : null}
</XStack>
{eventUsageText ? (
<Text fontSize="$xs" color={muted}>

View File

@@ -354,6 +354,7 @@ export default function MobileDashboardPage() {
navigate(adminPath('/mobile/events/new'));
}}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
packageType={activePackage.package_type ?? null}
remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at}
@@ -497,6 +498,7 @@ function PackageSummarySheet({
onClose,
onContinue,
packageName,
packageType,
remainingEvents,
purchasedAt,
expiresAt,
@@ -508,6 +510,7 @@ function PackageSummarySheet({
onClose: () => void;
onContinue: () => void;
packageName: string;
packageType: string | null;
remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined;
expiresAt: string | null | undefined;
@@ -523,8 +526,9 @@ function PackageSummarySheet({
package_limits: limits,
branding_allowed: (limits as any)?.branding_allowed ?? null,
watermark_allowed: (limits as any)?.watermark_allowed ?? null,
package_type: packageType,
} as any);
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents });
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }, { packageType });
const hasFeatures = resolvedFeatures.length > 0;
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');

View File

@@ -9,7 +9,19 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api';
import {
createEvent,
getEvent,
updateEvent,
getEventTypes,
getPackages,
getTenantPackagesOverview,
Package,
TenantEvent,
TenantEventType,
TenantPackageSummary,
trackOnboarding,
} from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
@@ -30,6 +42,7 @@ type FormState = {
autoApproveUploads: boolean;
tasksEnabled: boolean;
packageId: number | null;
servicePackageSlug: string | null;
};
export default function MobileEventFormPage() {
@@ -52,11 +65,14 @@ export default function MobileEventFormPage() {
autoApproveUploads: true,
tasksEnabled: true,
packageId: null,
servicePackageSlug: null,
});
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false);
const [packages, setPackages] = React.useState<Package[]>([]);
const [packagesLoading, setPackagesLoading] = React.useState(false);
const [kontingentOptions, setKontingentOptions] = React.useState<Array<{ slug: string; remaining: number }>>([]);
const [kontingentLoading, setKontingentLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false);
@@ -84,6 +100,7 @@ export default function MobileEventFormPage() {
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
(data.engagement_mode as string | undefined) !== 'photo_only',
packageId: null,
servicePackageSlug: null,
});
setError(null);
} catch (err) {
@@ -139,6 +156,75 @@ export default function MobileEventFormPage() {
})();
}, [isSuperAdmin, isEdit]);
React.useEffect(() => {
if (isEdit) {
return;
}
(async () => {
setKontingentLoading(true);
try {
const overview = await getTenantPackagesOverview();
const packages = overview.packages ?? [];
const active = packages.filter((pkg) => pkg.active && pkg.package_type === 'reseller');
const totals = new Map<string, number>();
active.forEach((pkg: TenantPackageSummary) => {
const slugValue = pkg.included_package_slug ?? 'standard';
if (!slugValue) {
return;
}
const remaining = Number.isFinite(pkg.remaining_events as number) ? Number(pkg.remaining_events) : 0;
if (remaining <= 0) {
return;
}
totals.set(slugValue, (totals.get(slugValue) ?? 0) + remaining);
});
const options = Array.from(totals.entries())
.map(([slugValue, remaining]) => ({ slug: slugValue, remaining }))
.sort((a, b) => a.slug.localeCompare(b.slug));
setKontingentOptions(options);
setForm((prev) => {
if (prev.servicePackageSlug || options.length === 0) {
return prev;
}
if (options.length === 1) {
return { ...prev, servicePackageSlug: options[0].slug };
}
const standard = options.find((row) => row.slug === 'standard');
return { ...prev, servicePackageSlug: standard?.slug ?? options[0].slug };
});
} catch {
setKontingentOptions([]);
} finally {
setKontingentLoading(false);
}
})();
}, [isEdit]);
const resolveServiceTierLabel = React.useCallback((slugValue: string) => {
if (slugValue === 'starter') {
return 'Starter';
}
if (slugValue === 'standard') {
return 'Standard';
}
if (slugValue === 'pro') {
return 'Premium';
}
return slugValue;
}, []);
async function handleSubmit() {
setSaving(true);
setError(null);
@@ -165,6 +251,7 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -188,6 +275,7 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -283,6 +371,34 @@ export default function MobileEventFormPage() {
</MobileField>
) : null}
{!isEdit && (kontingentLoading || kontingentOptions.length > 0) ? (
<MobileField label={t('eventForm.fields.servicePackage.label', 'Event-Level (Event-Kontingent)')}>
{kontingentLoading ? (
<Text fontSize="$sm" color={muted}>
{t('eventForm.fields.servicePackage.loading', 'Loading Event-Kontingente…')}
</Text>
) : (
<MobileSelect
value={form.servicePackageSlug ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, servicePackageSlug: String(e.target.value) }))}
>
<option value="">{t('eventForm.fields.servicePackage.placeholder', 'Select tier')}</option>
{kontingentOptions.map((opt) => (
<option key={opt.slug} value={opt.slug}>
{resolveServiceTierLabel(opt.slug)} · {opt.remaining} {t('eventForm.fields.servicePackage.events', 'Events')}
</option>
))}
</MobileSelect>
)}
<Text fontSize="$xs" color={muted}>
{t(
'eventForm.fields.servicePackage.help',
'Wählt das Event-Level. Pro Event wird 1 aus dem passenden Event-Kontingent verbraucht.',
)}
</Text>
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2">
<NativeDateTimeInput

View File

@@ -30,22 +30,39 @@ export default function MobilePackageShopPage() {
// Extract recommended feature from URL
const searchParams = new URLSearchParams(location.search);
const recommendedFeature = searchParams.get('feature');
const { data: catalog, isLoading: loadingCatalog } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
const forcedCatalogType = searchParams.get('type');
const { data: inventory, isLoading: loadingInventory } = useQuery({
queryKey: ['tenant-packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const catalogType: 'endcustomer' | 'reseller' =
forcedCatalogType === 'endcustomer' || forcedCatalogType === 'reseller'
? forcedCatalogType
: inventory?.activePackage?.package_type === 'reseller' ||
(inventory?.packages ?? []).some((entry) => entry.package_type === 'reseller')
? 'reseller'
: 'endcustomer';
const { data: catalog, isLoading: loadingCatalog } = useQuery({
queryKey: ['packages', catalogType],
queryFn: () => getPackages(catalogType),
});
const isLoading = loadingCatalog || loadingInventory;
if (isLoading) {
return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<MobileShell
title={
catalogType === 'reseller'
? t('shop.partner.title', 'Event-Kontingent kaufen')
: t('shop.title', 'Upgrade Package')
}
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$3">
<SkeletonCard height={150} />
<SkeletonCard height={150} />
@@ -65,7 +82,10 @@ export default function MobilePackageShopPage() {
const activePackageId = inventory?.activePackage?.package_id ?? null;
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
const recommendedPackageId =
catalogType === 'reseller'
? null
: selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
// Merge and sort packages
const sortedPackages = [...(catalog || [])].sort((a, b) => {
@@ -78,10 +98,14 @@ export default function MobilePackageShopPage() {
});
const packageEntries = sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id);
const isActive = inventory?.activePackage?.package_id === pkg.id;
const ownedEntries = (inventory?.packages ?? []).filter((entry) => entry.package_id === pkg.id && entry.active);
const owned = ownedEntries.length ? aggregateOwnedEntries(ownedEntries) : undefined;
const isActive = catalogType === 'reseller' ? false : inventory?.activePackage?.package_id === pkg.id;
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
const { isUpgrade, isDowngrade } =
catalogType === 'reseller'
? { isUpgrade: false, isDowngrade: false }
: classifyPackageChange(pkg, activeCatalogPackage);
return {
pkg,
@@ -94,9 +118,13 @@ export default function MobilePackageShopPage() {
});
return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<MobileShell
title={catalogType === 'reseller' ? t('shop.partner.title', 'Event-Kontingent kaufen') : t('shop.title', 'Upgrade Package')}
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$4">
{recommendedFeature && (
{catalogType !== 'reseller' && recommendedFeature && (
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
<XStack space="$2" alignItems="center">
<Sparkles size={16} color={primary} />
@@ -112,7 +140,9 @@ export default function MobilePackageShopPage() {
<YStack paddingHorizontal="$2">
<Text fontSize="$sm" color={muted}>
{t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
{catalogType === 'reseller'
? t('shop.partner.subtitle', 'Kaufe Event-Kontingente, um mehrere Events mit unseren Services umzusetzen.')
: t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
</Text>
</YStack>
@@ -140,6 +170,7 @@ export default function MobilePackageShopPage() {
<PackageShopCompareView
entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)}
catalogType={catalogType}
/>
) : (
packageEntries.map((entry) => (
@@ -151,6 +182,7 @@ export default function MobilePackageShopPage() {
isRecommended={entry.isRecommended}
isUpgrade={entry.isUpgrade}
isDowngrade={entry.isDowngrade}
catalogType={catalogType}
onSelect={() => setSelectedPackage(entry.pkg)}
/>
))
@@ -168,6 +200,7 @@ function PackageShopCard({
isRecommended,
isUpgrade,
isDowngrade,
catalogType,
onSelect
}: {
pkg: Package;
@@ -176,14 +209,17 @@ function PackageShopCard({
isRecommended?: any;
isUpgrade?: boolean;
isDowngrade?: boolean;
catalogType: 'endcustomer' | 'reseller';
onSelect: () => void
}) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const { t } = useTranslation('management');
const isResellerCatalog = catalogType === 'reseller';
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
const canSelect = canSelectPackage(isUpgrade, isActive);
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
return (
<MobileCard
@@ -202,9 +238,13 @@ function PackageShopCard({
{pkg.name}
</Text>
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
{!isResellerCatalog && isUpgrade && !isActive ? (
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
) : null}
{!isResellerCatalog && isDowngrade && !isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null}
{!isResellerCatalog && isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
</XStack>
<XStack space="$2" alignItems="center">
@@ -224,34 +264,58 @@ function PackageShopCard({
</XStack>
<YStack space="$1.5">
{pkg.max_photos ? (
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} />
{isResellerCatalog ? (
<>
{includedTierLabel ? (
<FeatureRow
label={t('shop.partner.includedTier', 'Inklusive Event-Level: {{tier}}', {
tier: includedTierLabel,
})}
/>
) : null}
{typeof pkg.max_events_per_year === 'number' ? (
<FeatureRow label={t('shop.partner.eventsIncluded', '{{count}} Events im Kontingent', { count: pkg.max_events_per_year })} />
) : null}
<FeatureRow label={t('shop.partner.recommendedUsage', 'Empfohlen innerhalb von 24 Monaten zu nutzen.')} />
</>
) : (
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} />
<>
{pkg.max_photos ? (
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} />
) : (
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} />
)}
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
) : null}
</>
)}
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
) : null}
{/* Render specific feature if it was requested */}
{getEnabledPackageFeatures(pkg)
.filter((key) => !pkg.max_photos || key !== 'photos')
.slice(0, 3)
.map((key) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))}
{!isResellerCatalog
? getEnabledPackageFeatures(pkg)
.filter((key) => !pkg.max_photos || key !== 'photos')
.slice(0, 3)
.map((key) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))
: null}
</YStack>
<CTAButton
label={
isActive
? t('shop.manage', 'Manage Plan')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
isResellerCatalog
? canSelect
? t('shop.partner.buy', 'Kaufen')
: t('shop.partner.unavailable', 'Nicht verfügbar')
: isActive
? t('shop.manage', 'Manage Plan')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
}
onPress={canSelect ? onSelect : undefined}
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
tone={isResellerCatalog ? (canSelect ? 'primary' : 'ghost') : isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect}
/>
</MobileCard>
@@ -280,9 +344,11 @@ type PackageEntry = {
function PackageShopCompareView({
entries,
onSelect,
catalogType,
}: {
entries: PackageEntry[];
onSelect: (pkg: Package) => void;
catalogType: 'endcustomer' | 'reseller';
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
@@ -308,9 +374,18 @@ function PackageShopCompareView({
if (row.limitKey === 'max_guests') {
return t('shop.compare.rows.guests', 'Guests');
}
if (row.limitKey === 'max_events_per_year') {
return t('shop.partner.compare.rows.events', 'Events im Kontingent');
}
return t('shop.compare.rows.days', 'Gallery days');
}
if (row.type === 'value') {
if (row.valueKey === 'included_package_slug') {
return t('shop.partner.compare.rows.includedTier', 'Inklusive Event-Level');
}
}
return t(`shop.features.${row.featureKey}`, row.featureKey);
};
@@ -362,13 +437,15 @@ function PackageShopCompareView({
{entry.isRecommended ? (
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
) : null}
{entry.isUpgrade && !entry.isActive ? (
{catalogType !== 'reseller' && entry.isUpgrade && !entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
) : null}
{entry.isDowngrade && !entry.isActive ? (
{catalogType !== 'reseller' && entry.isDowngrade && !entry.isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null}
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
{catalogType !== 'reseller' && entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>
) : null}
</XStack>
{statusLabel ? (
<Text fontSize="$xs" color={muted}>
@@ -391,6 +468,13 @@ function PackageShopCompareView({
{formatLimitValue(value)}
</Text>
);
} else if (row.type === 'value') {
content = (
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{resolveIncludedTierLabel(t, entry.pkg.included_package_slug ?? null) ??
t('shop.partner.compare.values.unknown', '—')}
</Text>
);
} else if (row.type === 'feature') {
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
content = (
@@ -425,12 +509,17 @@ function PackageShopCompareView({
<XStack paddingTop="$2">
<YStack width={labelWidth} />
{entries.map((entry) => {
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive);
const label = entry.isActive
? t('shop.manage', 'Manage Plan')
: entry.isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available');
const isResellerCatalog = catalogType === 'reseller';
const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
const label = isResellerCatalog
? canSelect
? t('shop.partner.buy', 'Kaufen')
: t('shop.partner.unavailable', 'Nicht verfügbar')
: entry.isActive
? t('shop.manage', 'Manage Plan')
: entry.isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available');
return (
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
@@ -438,7 +527,15 @@ function PackageShopCompareView({
label={label}
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
disabled={!canSelect}
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
tone={
catalogType === 'reseller'
? canSelect
? 'primary'
: 'ghost'
: entry.isActive || entry.isDowngrade
? 'ghost'
: 'primary'
}
/>
</YStack>
);
@@ -488,11 +585,16 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
await startCheckout(pkg.id);
};
const subtitle =
pkg.type === 'reseller'
? t('shop.partner.confirmSubtitle', 'Du kaufst:')
: t('shop.confirmSubtitle', 'You are upgrading to:');
return (
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
<YStack space="$4">
<MobileCard space="$2" borderColor={border}>
<Text fontSize="$sm" color={muted}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text>
<Text fontSize="$sm" color={muted}>{subtitle}</Text>
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
<Text fontSize="$lg" color={primary} fontWeight="700">
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
@@ -556,3 +658,43 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
</MobileShell>
);
}
function aggregateOwnedEntries(entries: TenantPackageSummary[]): TenantPackageSummary {
const remainingTotal = entries.reduce(
(total, entry) => total + (typeof entry.remaining_events === 'number' ? entry.remaining_events : 0),
0
);
const usedTotal = entries.reduce(
(total, entry) => total + (typeof entry.used_events === 'number' ? entry.used_events : 0),
0
);
return {
...entries[0],
used_events: usedTotal,
remaining_events: Number.isFinite(remainingTotal) ? remainingTotal : entries[0].remaining_events,
};
}
function resolveIncludedTierLabel(
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
slug: string | null
): string | null {
if (!slug) {
return null;
}
if (slug === 'starter') {
return t('shop.partner.tiers.starter', 'Starter');
}
if (slug === 'standard') {
return t('shop.partner.tiers.standard', 'Standard');
}
if (slug === 'pro') {
return t('shop.partner.tiers.premium', 'Premium');
}
return slug;
}

View File

@@ -6,6 +6,8 @@ const basePackage: TenantPackageSummary = {
id: 1,
package_id: 1,
package_name: 'Pro',
package_type: 'reseller',
included_package_slug: 'pro',
active: true,
used_events: 2,
remaining_events: 3,

View File

@@ -9,7 +9,12 @@ export type PackageComparisonRow =
| {
id: string;
type: 'limit';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days' | 'max_events_per_year';
}
| {
id: string;
type: 'value';
valueKey: 'included_package_slug';
}
| {
id: string;
@@ -62,6 +67,10 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
return { isUpgrade: false, isDowngrade: false };
}
if (pkg.type === 'reseller' || active.type === 'reseller') {
return { isUpgrade: false, isDowngrade: false };
}
const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg);
@@ -106,6 +115,10 @@ export function selectRecommendedPackageId(
return null;
}
if (packages.some((pkg) => pkg.type === 'reseller')) {
return null;
}
const candidates = feature === 'watermark_allowed'
? packages.filter((pkg) => pkg.watermark_allowed === true)
: packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
@@ -121,11 +134,20 @@ export function selectRecommendedPackageId(
}
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
const limitRows: PackageComparisonRow[] = [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const isResellerCatalog = packages.some(
(pkg) => pkg.type === 'reseller' || pkg.max_events_per_year !== undefined || pkg.included_package_slug !== undefined
);
const limitRows: PackageComparisonRow[] = isResellerCatalog
? [
{ id: 'value.included_package_slug', type: 'value', valueKey: 'included_package_slug' },
{ id: 'limit.max_events_per_year', type: 'limit', limitKey: 'max_events_per_year' },
]
: [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const featureKeys = new Set<string>();
packages.forEach((pkg) => {

View File

@@ -174,13 +174,19 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat
export function getPackageLimitEntries(
limits: Record<string, unknown> | null,
t: Translate,
usageOverrides: LimitUsageOverrides = {}
usageOverrides: LimitUsageOverrides = {},
options: { packageType?: string | null } = {}
): PackageLimitEntry[] {
if (!limits) {
return [];
}
return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({
const labels =
options.packageType === 'reseller'
? LIMIT_LABELS.filter(({ key }) => key === 'max_events_per_year')
: LIMIT_LABELS;
return labels.map(({ key, labelKey, fallback }) => ({
key,
label: t(labelKey, fallback),
value: formatLimitWithRemaining(
@@ -231,11 +237,11 @@ export function collectPackageFeatures(pkg: TenantPackageSummary): string[] {
}
});
if (pkg.branding_allowed) {
if (pkg.package_type !== 'reseller' && pkg.branding_allowed) {
features.add('branding_allowed');
}
if (pkg.watermark_allowed) {
if (pkg.package_type !== 'reseller' && pkg.watermark_allowed) {
features.add('watermark_allowed');
}

View File

@@ -29,7 +29,7 @@ i18n
},
backend: {
// Cache-bust to ensure fresh translations when files change.
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20251222',
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250116',
},
react: {
useSuspense: true,

View File

@@ -28,6 +28,7 @@ interface Package {
price: number;
events: number | null;
features: string[];
included_package_slug?: string | null;
max_events_per_year?: number | null;
limits?: {
max_photos?: number;
@@ -62,9 +63,10 @@ const sortPackagesByPrice = (packages: Package[]): Package[] =>
interface PackageComparisonProps {
packages: Package[];
variant: 'endcustomer' | 'reseller';
serviceTierNames?: Record<string, string>;
}
const buildDisplayFeatures = (pkg: Package): string[] => {
const buildDisplayFeatures = (pkg: Package, variant: 'endcustomer' | 'reseller'): string[] => {
const features = [...pkg.features];
const removeFeature = (key: string) => {
@@ -80,20 +82,22 @@ const buildDisplayFeatures = (pkg: Package): string[] => {
}
};
const watermarkFeature = resolveWatermarkFeatureKey(pkg);
['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature);
addFeature(watermarkFeature);
if (variant === 'endcustomer') {
const watermarkFeature = resolveWatermarkFeatureKey(pkg);
['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature);
addFeature(watermarkFeature);
if (pkg.branding_allowed) {
addFeature('custom_branding');
} else {
removeFeature('custom_branding');
if (pkg.branding_allowed) {
addFeature('custom_branding');
} else {
removeFeature('custom_branding');
}
}
return Array.from(new Set(features));
};
function PackageComparison({ packages, variant }: PackageComparisonProps) {
function PackageComparison({ packages, variant, serviceTierNames = {} }: PackageComparisonProps) {
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
@@ -135,12 +139,19 @@ function PackageComparison({ packages, variant }: PackageComparisonProps) {
{
key: 'price',
label: t('packages.price'),
value: (pkg: Package) => `${formatPrice(pkg)} / ${t('packages.billing_per_year')}`,
value: (pkg: Package) => `${formatPrice(pkg)} / ${t('packages.billing_per_kontingent')}`,
},
{
key: 'max_tenants',
label: t('packages.max_tenants'),
value: (pkg: Package) => pkg.limits?.max_tenants?.toLocaleString() ?? tCommon('unlimited'),
key: 'included_package_slug',
label: t('packages.included_package_label', 'Inklusive Event-Level'),
value: (pkg: Package) => {
const slug = pkg.included_package_slug ?? null;
if (!slug) {
return tCommon('unlimited');
}
return serviceTierNames[slug] ?? slug;
},
},
{
key: 'max_events_per_year',
@@ -150,24 +161,51 @@ function PackageComparison({ packages, variant }: PackageComparisonProps) {
},
];
const features = [
{
key: 'watermark',
label: t('packages.watermark_label'),
value: (pkg: Package) => t(`packages.feature_${resolveWatermarkFeatureKey(pkg)}`),
},
{
key: 'branding',
label: t('packages.feature_custom_branding'),
value: (pkg: Package) => (pkg.branding_allowed ? t('packages.available') : t('packages.not_available')),
},
{
key: 'support',
label: t('packages.feature_support'),
value: (pkg: Package) =>
pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'),
},
];
const features =
variant === 'endcustomer'
? [
{
key: 'watermark',
label: t('packages.watermark_label'),
value: (pkg: Package) => t(`packages.feature_${resolveWatermarkFeatureKey(pkg)}`),
},
{
key: 'branding',
label: t('packages.feature_custom_branding'),
value: (pkg: Package) => (pkg.branding_allowed ? t('packages.available') : t('packages.not_available')),
},
{
key: 'support',
label: t('packages.feature_support'),
value: (pkg: Package) =>
pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'),
},
]
: [
{
key: 'recommended_usage_window',
label: t('packages.recommended_usage_label', 'Empfehlung'),
value: () => t('packages.recommended_usage_window'),
},
{
key: 'support',
label: t('packages.feature_support'),
value: (pkg: Package) =>
pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'),
},
{
key: 'dashboard',
label: t('packages.feature_reseller_dashboard'),
value: (pkg: Package) =>
pkg.features.includes('reseller_dashboard') ? t('packages.available') : t('packages.not_available'),
},
{
key: 'reporting',
label: t('packages.feature_advanced_reporting'),
value: (pkg: Package) =>
pkg.features.includes('advanced_reporting') ? t('packages.available') : t('packages.not_available'),
},
];
return (
<div className="space-y-4">
@@ -258,6 +296,15 @@ interface PackagesProps {
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const serviceTierNames = useMemo(() => {
const map: Record<string, string> = {};
endcustomerPackages.forEach((pkg) => {
if (pkg?.slug) {
map[pkg.slug] = pkg.name;
}
});
return map;
}, [endcustomerPackages]);
const [isMobile, setIsMobile] = useState(false);
const dialogScrollRef = useRef<HTMLDivElement | null>(null);
const dialogHeadingRef = useRef<HTMLDivElement | null>(null);
@@ -499,6 +546,26 @@ type PackageMetric = {
value: string;
};
const resolveServiceTierLabel = (slug: string | null | undefined): string => {
if (!slug) {
return '';
}
if (slug === 'starter') {
return 'Starter';
}
if (slug === 'standard') {
return 'Standard';
}
if (slug === 'pro') {
return 'Premium';
}
return slug;
};
const resolvePackageMetrics = (
pkg: Package,
variant: 'endcustomer' | 'reseller',
@@ -508,11 +575,9 @@ const resolvePackageMetrics = (
if (variant === 'reseller') {
return [
{
key: 'max_tenants',
label: t('packages.max_tenants'),
value: pkg.limits?.max_tenants
? pkg.limits.max_tenants.toLocaleString()
: tCommon('unlimited'),
key: 'included_package_slug',
label: t('packages.included_package_label', 'Inklusive Event-Level'),
value: resolveServiceTierLabel(pkg.included_package_slug) || tCommon('unlimited'),
},
{
key: 'max_events_per_year',
@@ -522,9 +587,9 @@ const resolvePackageMetrics = (
: tCommon('unlimited'),
},
{
key: 'branding',
label: t('packages.feature_custom_branding'),
value: pkg.branding_allowed ? tCommon('included') : t('packages.feature_no_branding'),
key: 'recommended_usage_window',
label: t('packages.recommended_usage_label', 'Empfehlung'),
value: t('packages.recommended_usage_window'),
},
];
}
@@ -588,7 +653,7 @@ function PackageCard({
: `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`;
const cadenceLabel =
variant === 'reseller'
? t('packages.billing_per_year')
? t('packages.billing_per_kontingent')
: t('packages.billing_per_event');
const typeLabel =
variant === 'reseller' ? t('packages.subscription') : t('packages.one_time');
@@ -601,7 +666,7 @@ function PackageCard({
? t('packages.badge_starter')
: null;
const displayFeatures = buildDisplayFeatures(pkg);
const displayFeatures = buildDisplayFeatures(pkg, variant);
const keyFeatures = displayFeatures.slice(0, 3);
const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5);
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
@@ -736,8 +801,8 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
}) => {
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
const highlightFeatures = useMemo(
() => buildDisplayFeatures(packageData).slice(0, 5),
[packageData],
() => buildDisplayFeatures(packageData, variant).slice(0, 5),
[packageData, variant],
);
return (
@@ -756,7 +821,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
</p>
{packageData.price > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-300">
/ {variant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')}
/ {variant === 'reseller' ? t('packages.billing_per_kontingent') : t('packages.billing_per_event')}
</p>
)}
</div>
@@ -1015,7 +1080,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
/>
))}
</div>
<PackageComparison packages={orderedResellerPackages} variant="reseller" />
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
</TabsContent>
</Tabs>
</div>

View File

@@ -46,7 +46,7 @@ return [
'emotion' => 'Emotion',
'event_type' => 'Eventtyp',
'last_activity' => 'Letzte Aktivität',
'credits' => 'Credits',
'credits' => 'Event-Kontingent',
'settings' => 'Einstellungen',
'join' => 'Beitreten',
'unnamed' => 'Ohne Namen',
@@ -517,7 +517,7 @@ return [
'heading' => 'Uploads (14 Tage)',
],
'credit_alerts' => [
'low_balance_label' => 'Mandanten mit niedrigen Credits',
'low_balance_label' => 'Mandanten mit niedrigem Event-Kontingent',
'low_balance_desc' => 'Benötigen Betreuung',
'monthly_revenue_label' => 'Umsatz (Monat)',
'monthly_revenue_desc' => 'Aktueller Monat (:month)',
@@ -546,10 +546,10 @@ return [
'name' => 'Mandantenname',
'slug' => 'Slug',
'contact_email' => 'KontaktEMail',
'event_credits_balance' => 'EventCreditsKontostand',
'event_credits_balance' => 'Event-Kontingent',
'features' => 'Funktionen',
'total_revenue' => 'Gesamtumsatz',
'active_reseller_package' => 'Aktives Reseller-Paket',
'active_reseller_package' => 'Aktives Partner / Agentur-Paket',
'remaining_events' => 'Verbleibende Events',
'package_expires_at' => 'Ablaufdatum Paket',
'is_active' => 'Aktiv',
@@ -574,12 +574,12 @@ return [
'timeline' => 'Audit Timeline',
],
'actions' => [
'adjust_credits' => 'Credits anpassen',
'adjust_credits_delta' => 'Anzahl Credits (positiv/negativ)',
'adjust_credits_delta_hint' => 'Positive Werte fügen Credits hinzu, negative Werte ziehen ab.',
'adjust_credits' => 'Kontingent anpassen',
'adjust_credits_delta' => 'Event-Kontingent (positiv/negativ)',
'adjust_credits_delta_hint' => 'Positive Werte fügen Kontingent hinzu, negative Werte ziehen ab.',
'adjust_credits_reason' => 'Interne Notiz',
'adjust_credits_success_title' => 'Credits aktualisiert',
'adjust_credits_success_body' => 'Die Credits wurden um :delta verändert. Neuer Kontostand: :balance.',
'adjust_credits_success_title' => 'Kontingent aktualisiert',
'adjust_credits_success_body' => 'Das Kontingent wurde um :delta verändert. Neuer Stand: :balance.',
'lifecycle' => 'Lebenszyklus',
'activate' => 'Aktivieren',
'deactivate' => 'Deaktivieren',
@@ -663,7 +663,7 @@ return [
'fields' => [
'tenant' => 'Mandant',
'package' => 'Paket',
'credits' => 'Credits',
'credits' => 'Event-Kontingent',
'price' => 'Preis',
'currency' => 'Währung',
'platform' => 'Plattform',

View File

@@ -14,4 +14,38 @@ return [
'default_title' => 'Zugang verweigert',
'default_message' => 'Mit diesem QR-Zugang konnte kein Zugriff gewährt werden.',
],
'packages' => [
'event_tier_unavailable' => [
'title' => 'Gewähltes Event-Level nicht verfügbar',
'message' => 'Für das gewählte Event-Level ist kein Event-Kontingent verfügbar. Bitte wähle ein anderes Level oder kaufe das passende Event-Kontingent.',
],
'event_limit_exceeded' => [
'title' => 'Event-Kontingent aufgebraucht',
'message' => 'Dein aktuelles Event-Kontingent enthält keine freien Events mehr. Kaufe ein weiteres Event-Kontingent, um neue Events zu erstellen.',
],
'event_limit_missing' => [
'title' => 'Kein Paket zugewiesen',
'message' => 'Kaufe ein Event-Kontingent, um Events zu erstellen.',
],
'event_not_found' => [
'title' => 'Event nicht verfügbar',
'message' => 'Das gewählte Event wurde nicht gefunden oder gehört zu einem anderen Tenant.',
],
'event_package_missing' => [
'title' => 'Event-Paket fehlt',
'message' => 'Für dieses Event ist kein Paket hinterlegt. Weise ein Paket zu, um Uploads zu ermöglichen.',
],
'photo_limit_exceeded' => [
'title' => 'Foto-Limit erreicht',
'message' => 'Dieses Event hat sein Foto-Kontingent erreicht. Upgrade das Event-Paket, um weitere Uploads zu erlauben.',
],
'tenant_photo_limit_exceeded' => [
'title' => 'Tenant-Foto-Limit erreicht',
'message' => 'Dieser Tenant hat das Foto-Kontingent für dieses Event erreicht.',
],
'tenant_storage_limit_exceeded' => [
'title' => 'Tenant-Speicherlimit erreicht',
'message' => 'Dieser Tenant hat sein Speicher-Kontingent erreicht.',
],
],
];

View File

@@ -47,21 +47,34 @@
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
"cta_explore": "Pakete entdecken",
"tab_endcustomer": "Endkunden",
"tab_reseller": "Reseller & Agenturen",
"tab_reseller": "Partner / Agentur",
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
"section_reseller": "Packages für Reseller (Jährliches Abo)",
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
"free": "Kostenlos",
"one_time": "Einmalkauf",
"subscription": "Abo",
"subscription": "Einmalkauf",
"year": "Jahr",
"billing_per_event": "pro Event",
"billing_per_kontingent": "pro Kontingent",
"available": "Verfügbar",
"not_available": "Nicht verfügbar",
"standard_support": "Standard-Support",
"priority_support": "Priorisierter Support",
"badge_best_value": "Bestes PreisLeistungsVerhältnis",
"badge_most_popular": "Beliebt",
"badge_starter": "Start",
"view_details": "Details anzeigen",
"included_package_label": "Inklusive Event-Level",
"recommended_usage_label": "Empfehlung",
"max_photos": "Fotos",
"max_guests": "Gäste",
"gallery_days": "Tage Galerie",
"max_events_year": "Events/Jahr",
"max_events_year": "Events enthalten",
"recommended_usage_window": "Empfohlen innerhalb von 24 Monaten zu nutzen.",
"buy_now": "Jetzt kaufen",
"subscribe_now": "Jetzt abonnieren",
"subscribe_now": "Jetzt kaufen",
"register_buy": "Registrieren und kaufen",
"register_subscribe": "Registrieren und abonnieren",
"register_subscribe": "Registrieren und kaufen",
"faq_title": "Häufige Fragen zu Packages",
"faq_q1": "Was ist ein Package?",
"faq_a1": "Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.",
@@ -93,7 +106,7 @@
"feature_custom_branding": "Benutzerdefiniertes Branding",
"feature_advanced_reporting": "Erweiterte Berichterstattung",
"for_endcustomers": "Für Endkunden",
"for_resellers": "Für Reseller",
"for_resellers": "Für Partner / Agenturen",
"details_show": "Details anzeigen",
"comparison_title": "Packages vergleichen",
"price": "Preis",
@@ -104,10 +117,10 @@
"no_watermark": "Kein Wasserzeichen",
"custom_branding": "Benutzerdefiniertes Branding",
"max_tenants": "Max. Tenants",
"max_events": "Max. Events/Jahr",
"max_events": "Events im Kontingent",
"faq_free": "Was ist das Free Package?",
"faq_upgrade": "Kann ich upgraden?",
"faq_reseller": "Was für Reseller?",
"faq_reseller": "Was für Partner / Agenturen?",
"faq_payment": "Zahlung sicher?"
},
"blog": {

View File

@@ -7,21 +7,21 @@ return [
'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.',
'cta_explore' => 'Packages entdecken',
'tab_endcustomer' => 'Endkunden',
'tab_reseller' => 'Reseller & Agenturen',
'tab_reseller' => 'Partner / Agenturen',
'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)',
'section_reseller' => 'Packages für Reseller (Jährliches Abo)',
'section_reseller' => 'Packages für Partner / Agenturen (Event-Kontingent)',
'free' => 'Kostenlos',
'one_time' => 'Einmalkauf',
'subscription' => 'Abo',
'subscription' => 'Event-Kontingent',
'year' => 'Jahr',
'max_photos' => 'Fotos',
'max_guests' => 'Gäste',
'gallery_days' => 'Tage Galerie',
'max_events_year' => 'Events/Jahr',
'max_events_year' => 'Events enthalten',
'buy_now' => 'Jetzt kaufen',
'subscribe_now' => 'Jetzt abonnieren',
'subscribe_now' => 'Event-Kontingent kaufen',
'register_buy' => 'Registrieren und kaufen',
'register_subscribe' => 'Registrieren und abonnieren',
'register_subscribe' => 'Registrieren und kaufen',
'faq_title' => 'Häufige Fragen zu Packages',
'faq_q1' => 'Was ist ein Package?',
'faq_a1' => 'Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.',
@@ -49,7 +49,7 @@ return [
'feature_limited_sharing' => 'Begrenztes Teilen',
'feature_no_branding' => 'Kein Branding',
'feature_0' => 'Basis-Feature',
'feature_reseller_dashboard' => 'Reseller-Dashboard',
'feature_reseller_dashboard' => 'Partner-Dashboard',
'feature_custom_branding' => 'Benutzerdefiniertes Branding',
'feature_advanced_reporting' => 'Erweiterte Berichterstattung',
'badge_most_popular' => 'Beliebteste Wahl',
@@ -57,10 +57,12 @@ return [
'badge_starter' => 'Perfekt für den Start',
'billing_per_event' => 'pro Event',
'billing_per_year' => 'pro Jahr',
'billing_per_kontingent' => 'pro Kontingent',
'more_features' => '+:count weitere Features',
'max_photos_label' => 'Max. Fotos',
'max_guests_label' => 'Max. Gäste',
'gallery_days_label' => 'Galerie-Tage',
'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.',
'feature_overview' => 'Feature-Überblick',
'order_hint' => 'Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.',
'features_label' => 'Features',
@@ -109,7 +111,7 @@ return [
'summary_title' => 'Ihre Bestellung',
'package_label' => 'Ausgewähltes Package',
'billing_type_one_time' => 'Einmalkauf (pro Event)',
'billing_type_subscription' => 'Abo (wiederkehrend)',
'billing_type_subscription' => 'Einmalkauf (Kontingent)',
'legal_links_intro' => 'Mit Abschluss des Kaufs akzeptieren Sie unsere',
'link_terms' => 'AGB',
'link_privacy' => 'Datenschutzerklärung',
@@ -118,7 +120,7 @@ return [
'checkbox_terms_error' => 'Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.',
'checkbox_digital_content_label' => 'Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.',
'checkbox_digital_content_error' => 'Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.',
'hint_subscription_withdrawal' => 'Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.',
'hint_subscription_withdrawal' => 'Bei Einmalkäufen haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.',
],
'legal' => [
'imprint' => 'Impressum',

View File

@@ -46,7 +46,7 @@ return [
'emotion' => 'Emotion',
'event_type' => 'Event Type',
'last_activity' => 'Last activity',
'credits' => 'Credits',
'credits' => 'Event kontingent',
'settings' => 'Settings',
'join' => 'Join',
'unnamed' => 'Unnamed',
@@ -503,7 +503,7 @@ return [
'heading' => 'Uploads (14 days)',
],
'credit_alerts' => [
'low_balance_label' => 'Tenants with low credits',
'low_balance_label' => 'Tenants with low event kontingent',
'low_balance_desc' => 'May require follow-up',
'monthly_revenue_label' => 'Revenue (month)',
'monthly_revenue_desc' => 'Current month (:month)',
@@ -532,10 +532,10 @@ return [
'name' => 'Tenant name',
'slug' => 'Slug',
'contact_email' => 'Contact email',
'event_credits_balance' => 'Event credits balance',
'event_credits_balance' => 'Event kontingent',
'features' => 'Features',
'total_revenue' => 'Total revenue',
'active_reseller_package' => 'Active reseller package',
'active_reseller_package' => 'Active partner / agency package',
'remaining_events' => 'Remaining events',
'package_expires_at' => 'Package expires at',
'is_active' => 'Active',
@@ -560,12 +560,12 @@ return [
'timeline' => 'Audit timeline',
],
'actions' => [
'adjust_credits' => 'Adjust credits',
'adjust_credits_delta' => 'Credit delta (positive/negative)',
'adjust_credits_delta_hint' => 'Positive values grant credits, negative values deduct them.',
'adjust_credits' => 'Adjust kontingent',
'adjust_credits_delta' => 'Event kontingent delta (positive/negative)',
'adjust_credits_delta_hint' => 'Positive values add kontingent, negative values deduct it.',
'adjust_credits_reason' => 'Internal note',
'adjust_credits_success_title' => 'Credits updated',
'adjust_credits_success_body' => 'Credits changed by :delta. New balance: :balance.',
'adjust_credits_success_title' => 'Kontingent updated',
'adjust_credits_success_body' => 'Kontingent changed by :delta. New balance: :balance.',
'lifecycle' => 'Lifecycle',
'activate' => 'Activate',
'deactivate' => 'Deactivate',
@@ -649,7 +649,7 @@ return [
'fields' => [
'tenant' => 'Tenant',
'package' => 'Package',
'credits' => 'Credits',
'credits' => 'Event kontingent',
'price' => 'Price',
'currency' => 'Currency',
'platform' => 'Platform',

View File

@@ -14,4 +14,38 @@ return [
'default_title' => 'Access denied',
'default_message' => 'We could not grant access with this QR link.',
],
'packages' => [
'event_tier_unavailable' => [
'title' => 'Selected tier unavailable',
'message' => 'No Event-Kontingent is available for the selected event tier. Choose a different tier or purchase the matching Event-Kontingent.',
],
'event_limit_exceeded' => [
'title' => 'Event-Kontingent depleted',
'message' => 'Your current Event-Kontingent has no remaining events. Purchase another Event-Kontingent to create new events.',
],
'event_limit_missing' => [
'title' => 'No package assigned',
'message' => 'Purchase an Event-Kontingent to create events.',
],
'event_not_found' => [
'title' => 'Event not accessible',
'message' => 'The selected event could not be found or belongs to another tenant.',
],
'event_package_missing' => [
'title' => 'Event package missing',
'message' => 'No package is attached to this event. Assign a package to enable uploads.',
],
'photo_limit_exceeded' => [
'title' => 'Photo upload limit reached',
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
],
'tenant_photo_limit_exceeded' => [
'title' => 'Tenant photo limit reached',
'message' => 'This tenant has reached its photo allowance for the event.',
],
'tenant_storage_limit_exceeded' => [
'title' => 'Tenant storage limit reached',
'message' => 'This tenant has reached its storage allowance.',
],
],
];

View File

@@ -47,21 +47,34 @@
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.",
"cta_explore": "Discover Packages",
"tab_endcustomer": "End Customers",
"tab_reseller": "Resellers & Agencies",
"tab_reseller": "Partner / Agency",
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
"section_reseller": "Packages for Resellers (Annual Subscription)",
"section_reseller": "Packages for Partner / Agencies (Event-Kontingent)",
"free": "Free",
"one_time": "One-time purchase",
"subscription": "Subscription",
"subscription": "One-time purchase",
"year": "Year",
"billing_per_event": "per event",
"billing_per_kontingent": "per bundle",
"available": "Available",
"not_available": "Not available",
"standard_support": "Standard support",
"priority_support": "Priority support",
"badge_best_value": "Best value",
"badge_most_popular": "Most popular",
"badge_starter": "Starter",
"view_details": "View details",
"included_package_label": "Included event tier",
"recommended_usage_label": "Recommendation",
"max_photos": "Photos",
"max_guests": "Guests",
"gallery_days": "Gallery Days",
"max_events_year": "Events/Year",
"max_events_year": "Events included",
"recommended_usage_window": "Recommended to use within 24 months.",
"buy_now": "Buy Now",
"subscribe_now": "Subscribe Now",
"subscribe_now": "Buy Now",
"register_buy": "Register and Buy",
"register_subscribe": "Register and Subscribe",
"register_subscribe": "Register and Buy",
"faq_title": "Frequently Asked Questions about Packages",
"faq_q1": "What is a Package?",
"faq_a1": "A Package defines limits and features for your event, e.g. number of photos and gallery duration.",
@@ -93,7 +106,7 @@
"feature_custom_branding": "Custom Branding",
"feature_advanced_reporting": "Advanced Reporting",
"for_endcustomers": "For End Customers",
"for_resellers": "For Resellers",
"for_resellers": "For Partner / Agencies",
"details_show": "Show Details",
"comparison_title": "Compare Packages",
"price": "Price",
@@ -104,10 +117,10 @@
"no_watermark": "No Watermark",
"custom_branding": "Custom Branding",
"max_tenants": "Max. Tenants",
"max_events": "Max. Events/Year",
"max_events": "Events in kontingent",
"faq_free": "What is the Free Package?",
"faq_upgrade": "Can I upgrade?",
"faq_reseller": "What for Resellers?",
"faq_reseller": "What for Partner / Agencies?",
"faq_payment": "Payment secure?"
},
"blog": {

View File

@@ -7,21 +7,21 @@ return [
'hero_description' => 'From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.',
'cta_explore' => 'Discover Packages',
'tab_endcustomer' => 'End Customers',
'tab_reseller' => 'Resellers & Agencies',
'tab_reseller' => 'Partner / Agencies',
'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)',
'section_reseller' => 'Packages for Resellers (Annual Subscription)',
'section_reseller' => 'Packages for Partner / Agencies (Event kontingent)',
'free' => 'Free',
'one_time' => 'One-time purchase',
'subscription' => 'Subscription',
'subscription' => 'Event kontingent',
'year' => 'Year',
'max_photos' => 'Photos',
'max_guests' => 'Guests',
'gallery_days' => 'Gallery Days',
'max_events_year' => 'Events/Year',
'max_events_year' => 'Events included',
'buy_now' => 'Buy Now',
'subscribe_now' => 'Subscribe Now',
'subscribe_now' => 'Buy event kontingent',
'register_buy' => 'Register and Buy',
'register_subscribe' => 'Register and Subscribe',
'register_subscribe' => 'Register and buy',
'faq_title' => 'Frequently Asked Questions about Packages',
'faq_q1' => 'What is a Package?',
'faq_a1' => 'A Package defines limits and features for your event, e.g. number of photos and gallery duration.',
@@ -49,7 +49,7 @@ return [
'feature_limited_sharing' => 'Limited Sharing',
'feature_no_branding' => 'No Branding',
'feature_0' => 'Basic Feature',
'feature_reseller_dashboard' => 'Reseller Dashboard',
'feature_reseller_dashboard' => 'Partner dashboard',
'feature_custom_branding' => 'Custom Branding',
'feature_advanced_reporting' => 'Advanced Reporting',
'badge_most_popular' => 'Most Popular',
@@ -57,6 +57,8 @@ return [
'badge_starter' => 'Perfect Starter',
'billing_per_event' => 'per event',
'billing_per_year' => 'per year',
'billing_per_kontingent' => 'per bundle',
'recommended_usage_window' => 'Recommended to use within 24 months.',
'more_features' => '+:count more features',
'max_photos_label' => 'Max. photos',
'max_guests_label' => 'Max. guests',
@@ -109,7 +111,7 @@ return [
'summary_title' => 'Your order',
'package_label' => 'Selected package',
'billing_type_one_time' => 'One-time purchase (per event)',
'billing_type_subscription' => 'Subscription (recurring)',
'billing_type_subscription' => 'One-time purchase (kontingent)',
'legal_links_intro' => 'By completing your order you accept our',
'link_terms' => 'Terms & Conditions',
'link_privacy' => 'Privacy Policy',
@@ -118,7 +120,7 @@ return [
'checkbox_terms_error' => 'Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.',
'checkbox_digital_content_label' => 'I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.',
'checkbox_digital_content_error' => 'Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.',
'hint_subscription_withdrawal' => 'For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.',
'hint_subscription_withdrawal' => 'For one-time purchases, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.',
],
'legal' => [
'imprint' => 'Imprint',