Update partner packages, copy, and demo switcher
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user