Update backend photobooth connect API
This commit is contained in:
@@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
export async function createTenantPaddleCheckout(
|
||||
packageId: number,
|
||||
urls?: { success_url?: string; return_url?: string }
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string }> {
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2468,12 +2468,22 @@ export async function createTenantPaddleCheckout(
|
||||
return_url: urls?.return_url,
|
||||
}),
|
||||
});
|
||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>(
|
||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
|
||||
response,
|
||||
'Failed to create checkout'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTenantPackageCheckoutStatus(
|
||||
checkoutSessionId: string,
|
||||
): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
|
||||
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
|
||||
response,
|
||||
'Failed to load checkout status'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -34,6 +34,27 @@
|
||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||
"portal": "Paddle-Portal konnte nicht geöffnet werden."
|
||||
},
|
||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
|
||||
"checkoutPendingTitle": "Paket wird aktiviert",
|
||||
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
|
||||
"checkoutPendingBadge": "Ausstehend",
|
||||
"checkoutPendingRefresh": "Aktualisieren",
|
||||
"checkoutPendingDismiss": "Ausblenden",
|
||||
"checkoutFailedTitle": "Checkout fehlgeschlagen",
|
||||
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
|
||||
"checkoutFailedBadge": "Fehlgeschlagen",
|
||||
"checkoutFailedRetry": "Erneut versuchen",
|
||||
"checkoutFailedDismiss": "Ausblenden",
|
||||
"checkoutActionTitle": "Aktion erforderlich",
|
||||
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
|
||||
"checkoutActionBadge": "Aktion nötig",
|
||||
"checkoutActionButton": "Checkout fortsetzen",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "Die Zahlung wurde abgelehnt.",
|
||||
"paddle_cancelled": "Der Checkout wurde abgebrochen."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
"title": "Rechnungen & Zahlungen",
|
||||
@@ -176,6 +197,8 @@
|
||||
},
|
||||
"common": {
|
||||
"all": "Alle",
|
||||
"anonymous": "Anonym",
|
||||
"error": "Etwas ist schiefgelaufen",
|
||||
"loadMore": "Mehr laden",
|
||||
"processing": "Verarbeite …",
|
||||
"select": "Auswählen",
|
||||
@@ -2875,16 +2898,25 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"upgradeAction": "Upgrade auf Premium",
|
||||
"kpiTitle": "Event-Überblick",
|
||||
"kpiUploads": "Uploads",
|
||||
"kpiContributors": "Beitragende",
|
||||
"kpiLikes": "Likes",
|
||||
"activityTitle": "Aktivitäts-Zeitachse",
|
||||
"timeframe": "Letzte {{hours}} Stunden",
|
||||
"timeframeHint": "Ältere Aktivität ausgeblendet",
|
||||
"uploadsPerHour": "Uploads pro Stunde",
|
||||
"noActivity": "Noch keine Uploads",
|
||||
"emptyActionShareQr": "QR-Code teilen",
|
||||
"contributorsTitle": "Top-Beitragende",
|
||||
"likesCount": "{{count}} Likes",
|
||||
"likesCount_one": "{{count}} Like",
|
||||
"likesCount_other": "{{count}} Likes",
|
||||
"noContributors": "Noch keine Beitragenden",
|
||||
"emptyActionInvite": "Gäste einladen",
|
||||
"tasksTitle": "Beliebte Aufgaben",
|
||||
"noTasks": "Noch keine Aufgabenaktivität",
|
||||
"emptyActionOpenTasks": "Aufgaben öffnen",
|
||||
"lockedTitle": "Analytics freischalten",
|
||||
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
|
||||
},
|
||||
@@ -2893,6 +2925,26 @@
|
||||
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
||||
"recommendationTitle": "Empfohlen für dich",
|
||||
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
||||
"compare": {
|
||||
"title": "Pakete vergleichen",
|
||||
"helper": "Wische, um Pakete nebeneinander zu vergleichen.",
|
||||
"toggleCards": "Karten",
|
||||
"toggleCompare": "Vergleichen",
|
||||
"headers": {
|
||||
"plan": "Paket",
|
||||
"price": "Preis"
|
||||
},
|
||||
"rows": {
|
||||
"photos": "Fotos",
|
||||
"guests": "Gäste",
|
||||
"days": "Galerietage"
|
||||
},
|
||||
"values": {
|
||||
"included": "Enthalten",
|
||||
"notIncluded": "Nicht enthalten",
|
||||
"unlimited": "Unbegrenzt"
|
||||
}
|
||||
},
|
||||
"select": "Auswählen",
|
||||
"manage": "Paket verwalten",
|
||||
"limits": {
|
||||
@@ -2906,7 +2958,13 @@
|
||||
},
|
||||
"features": {
|
||||
"advanced_analytics": "Erweiterte Analytics",
|
||||
"basic_uploads": "Basis-Uploads",
|
||||
"custom_branding": "Eigenes Branding",
|
||||
"custom_tasks": "Benutzerdefinierte Aufgaben",
|
||||
"limited_sharing": "Begrenztes Teilen",
|
||||
"live_slideshow": "Live-Slideshow",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"unlimited_sharing": "Unbegrenztes Teilen",
|
||||
"watermark_removal": "Kein Wasserzeichen"
|
||||
},
|
||||
"status": {
|
||||
@@ -2918,7 +2976,9 @@
|
||||
},
|
||||
"badges": {
|
||||
"recommended": "Empfohlen",
|
||||
"active": "Aktiv"
|
||||
"active": "Aktiv",
|
||||
"upgrade": "Upgrade",
|
||||
"downgrade": "Downgrade"
|
||||
},
|
||||
"confirmTitle": "Kauf bestätigen",
|
||||
"confirmSubtitle": "Du upgradest auf:",
|
||||
@@ -2931,6 +2991,7 @@
|
||||
"payNow": "Jetzt zahlen",
|
||||
"errors": {
|
||||
"checkout": "Checkout fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"selectDisabled": "Nicht verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,27 @@
|
||||
"more": "Unable to load more entries.",
|
||||
"portal": "Unable to open the Paddle portal."
|
||||
},
|
||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||
"checkoutCancelled": "Checkout was cancelled.",
|
||||
"checkoutActivated": "Your package is now active.",
|
||||
"checkoutPendingTitle": "Activating your package",
|
||||
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
|
||||
"checkoutPendingBadge": "Pending",
|
||||
"checkoutPendingRefresh": "Refresh",
|
||||
"checkoutPendingDismiss": "Dismiss",
|
||||
"checkoutFailedTitle": "Checkout failed",
|
||||
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
|
||||
"checkoutFailedBadge": "Failed",
|
||||
"checkoutFailedRetry": "Try again",
|
||||
"checkoutFailedDismiss": "Dismiss",
|
||||
"checkoutActionTitle": "Action required",
|
||||
"checkoutActionBody": "Complete your payment to activate the package.",
|
||||
"checkoutActionBadge": "Action needed",
|
||||
"checkoutActionButton": "Continue checkout",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "The payment was declined.",
|
||||
"paddle_cancelled": "The checkout was cancelled."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
"title": "Invoices & payments",
|
||||
@@ -172,6 +193,8 @@
|
||||
},
|
||||
"common": {
|
||||
"all": "All",
|
||||
"anonymous": "Anonymous",
|
||||
"error": "Something went wrong",
|
||||
"loadMore": "Load more",
|
||||
"processing": "Processing…",
|
||||
"select": "Select",
|
||||
@@ -2879,16 +2902,25 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"upgradeAction": "Upgrade to Premium",
|
||||
"kpiTitle": "Event snapshot",
|
||||
"kpiUploads": "Uploads",
|
||||
"kpiContributors": "Contributors",
|
||||
"kpiLikes": "Likes",
|
||||
"activityTitle": "Activity Timeline",
|
||||
"timeframe": "Last {{hours}} hours",
|
||||
"timeframeHint": "Older activity hidden",
|
||||
"uploadsPerHour": "Uploads per hour",
|
||||
"noActivity": "No uploads yet",
|
||||
"emptyActionShareQr": "Share your QR code",
|
||||
"contributorsTitle": "Top Contributors",
|
||||
"likesCount": "{{count}} likes",
|
||||
"likesCount_one": "{{count}} like",
|
||||
"likesCount_other": "{{count}} likes",
|
||||
"noContributors": "No contributors yet",
|
||||
"emptyActionInvite": "Invite guests",
|
||||
"tasksTitle": "Popular Tasks",
|
||||
"noTasks": "No task activity yet",
|
||||
"emptyActionOpenTasks": "Open tasks",
|
||||
"lockedTitle": "Unlock Analytics",
|
||||
"lockedBody": "Get deep insights into your event engagement with the Premium package."
|
||||
},
|
||||
@@ -2897,6 +2929,26 @@
|
||||
"subtitle": "Choose a package to unlock more features and limits.",
|
||||
"recommendationTitle": "Recommended for you",
|
||||
"recommendationBody": "The highlighted package includes the feature you requested.",
|
||||
"compare": {
|
||||
"title": "Compare plans",
|
||||
"helper": "Swipe to compare packages side by side.",
|
||||
"toggleCards": "Cards",
|
||||
"toggleCompare": "Compare",
|
||||
"headers": {
|
||||
"plan": "Plan",
|
||||
"price": "Price"
|
||||
},
|
||||
"rows": {
|
||||
"photos": "Photos",
|
||||
"guests": "Guests",
|
||||
"days": "Gallery days"
|
||||
},
|
||||
"values": {
|
||||
"included": "Included",
|
||||
"notIncluded": "Not included",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
},
|
||||
"select": "Select",
|
||||
"manage": "Manage Plan",
|
||||
"limits": {
|
||||
@@ -2910,7 +2962,13 @@
|
||||
},
|
||||
"features": {
|
||||
"advanced_analytics": "Advanced Analytics",
|
||||
"basic_uploads": "Basic uploads",
|
||||
"custom_branding": "Custom Branding",
|
||||
"custom_tasks": "Custom tasks",
|
||||
"limited_sharing": "Limited sharing",
|
||||
"live_slideshow": "Live slideshow",
|
||||
"priority_support": "Priority support",
|
||||
"unlimited_sharing": "Unlimited sharing",
|
||||
"watermark_removal": "No Watermark"
|
||||
},
|
||||
"status": {
|
||||
@@ -2922,7 +2980,9 @@
|
||||
},
|
||||
"badges": {
|
||||
"recommended": "Recommended",
|
||||
"active": "Active"
|
||||
"active": "Active",
|
||||
"upgrade": "Upgrade",
|
||||
"downgrade": "Downgrade"
|
||||
},
|
||||
"confirmTitle": "Confirm Purchase",
|
||||
"confirmSubtitle": "You are upgrading to:",
|
||||
@@ -2935,6 +2995,7 @@
|
||||
"payNow": "Pay Now",
|
||||
"errors": {
|
||||
"checkout": "Checkout failed"
|
||||
}
|
||||
},
|
||||
"selectDisabled": "Not available"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
PaddleTransactionSummary,
|
||||
} from '../api';
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
getPackageFeatureLabel,
|
||||
getPackageLimitEntries,
|
||||
} from './lib/packageSummary';
|
||||
import {
|
||||
PendingCheckout,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -40,6 +49,11 @@ export default function MobileBillingPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
|
||||
const lastCheckoutStatusRef = React.useRef<string | null>(null);
|
||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const supportEmail = 'support@fotospiel.de';
|
||||
@@ -95,6 +109,11 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [portalBusy, t]);
|
||||
|
||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||
setPendingCheckout(next);
|
||||
storePendingCheckout(next);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -108,6 +127,115 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [location.hash, loading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const checkout = params.get('checkout');
|
||||
const packageId = params.get('package_id');
|
||||
if (!checkout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkout === 'success') {
|
||||
const packageIdNumber = packageId ? Number(packageId) : null;
|
||||
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
|
||||
const pendingEntry = {
|
||||
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
|
||||
checkoutSessionId: existingSessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
persistPendingCheckout(pendingEntry);
|
||||
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
|
||||
} else if (checkout === 'cancel') {
|
||||
persistPendingCheckout(null);
|
||||
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
||||
}
|
||||
|
||||
params.delete('checkout');
|
||||
params.delete('package_id');
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
|
||||
persistPendingCheckout(null);
|
||||
}
|
||||
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout?.checkoutSessionId) {
|
||||
setCheckoutStatus(null);
|
||||
setCheckoutStatusReason(null);
|
||||
setCheckoutActionUrl(null);
|
||||
lastCheckoutStatusRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setCheckoutStatus(result.status);
|
||||
setCheckoutStatusReason(result.reason ?? null);
|
||||
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
|
||||
|
||||
const lastStatus = lastCheckoutStatusRef.current;
|
||||
lastCheckoutStatusRef.current = result.status;
|
||||
|
||||
if (result.status === 'completed') {
|
||||
persistPendingCheckout(null);
|
||||
if (lastStatus !== 'completed') {
|
||||
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
|
||||
}
|
||||
await load();
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed' || result.status === 'cancelled') {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="profile"
|
||||
@@ -127,6 +255,109 @@ export default function MobileBillingPage() {
|
||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
|
||||
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={danger}>
|
||||
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutFailedBody',
|
||||
'The payment did not complete. You can try again or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
{checkoutStatusReason ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<PillBadge tone="danger">
|
||||
{t('billing.checkoutFailedBadge', 'Failed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedRetry', 'Try again')}
|
||||
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutActionTitle', 'Action required')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutActionBadge', 'Action needed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
||||
onPress={() => {
|
||||
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
navigate(adminPath('/mobile/billing/shop'));
|
||||
}}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutPendingBody',
|
||||
'This can take a few minutes. We will update this screen once the package is active.'
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutPendingBadge', 'Pending')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
||||
<CTAButton
|
||||
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$2" ref={packagesRef as any}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -535,4 +766,4 @@ function formatDate(value: string | null | undefined): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@ import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react';
|
||||
import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileCard, CTAButton, KpiTile, SkeletonCard } from './components/Primitives';
|
||||
import { getEventAnalytics, EventAnalytics } from '../api';
|
||||
import { ApiError } from '../lib/apiError';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
|
||||
import { adminPath } from '../constants';
|
||||
|
||||
export default function MobileEventAnalyticsPage() {
|
||||
@@ -97,9 +98,17 @@ export default function MobileEventAnalyticsPage() {
|
||||
const hasTimeline = timeline.length > 0;
|
||||
const hasContributors = contributors.length > 0;
|
||||
const hasTasks = tasks.length > 0;
|
||||
const fallbackHours = 12;
|
||||
const rawTimelineHours = resolveTimelineHours(timeline.map((point) => point.timestamp), fallbackHours);
|
||||
const timeframeHours = Math.min(rawTimelineHours, fallbackHours);
|
||||
const isTimeframeCapped = rawTimelineHours > fallbackHours;
|
||||
|
||||
// Prepare chart data
|
||||
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
||||
const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
|
||||
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
|
||||
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
|
||||
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
|
||||
const totalContributors = contributors.length;
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
@@ -108,6 +117,28 @@ export default function MobileEventAnalyticsPage() {
|
||||
onBack={() => navigate(-1)}
|
||||
>
|
||||
<YStack space="$4">
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('analytics.kpiTitle', 'Event snapshot')}
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<KpiTile
|
||||
icon={TrendingUp}
|
||||
label={t('analytics.kpiUploads', 'Uploads')}
|
||||
value={totalUploads}
|
||||
/>
|
||||
<KpiTile
|
||||
icon={Users}
|
||||
label={t('analytics.kpiContributors', 'Contributors')}
|
||||
value={totalContributors}
|
||||
/>
|
||||
<KpiTile
|
||||
icon={Trophy}
|
||||
label={t('analytics.kpiLikes', 'Likes')}
|
||||
value={totalLikes}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
{/* Activity Timeline */}
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -116,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
|
||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
|
||||
</Text>
|
||||
{isTimeframeCapped ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.timeframeHint', 'Older activity hidden')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{hasTimeline ? (
|
||||
<YStack height={180} justifyContent="flex-end" space="$2">
|
||||
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||
{timeline.map((point, index) => {
|
||||
const heightPercent = (point.count / maxCount) * 100;
|
||||
const heightPercent = (point.count / maxTimelineCount) * 100;
|
||||
const date = parseISO(point.timestamp);
|
||||
// Show label every 3rd point or if few points
|
||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||
@@ -138,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
|
||||
/>
|
||||
{showLabel && (
|
||||
<Text fontSize={10} color={muted} numberOfLines={1}>
|
||||
{format(date, 'HH:mm')}
|
||||
{format(date, 'HH:mm', { locale: dateLocale })}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
@@ -150,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noActivity', 'No uploads yet')}
|
||||
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
@@ -196,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
))}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noContributors', 'No contributors yet')}
|
||||
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
@@ -212,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
|
||||
{hasTasks ? (
|
||||
<YStack space="$3">
|
||||
{tasks.map((task) => {
|
||||
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
|
||||
const percent = (task.count / maxTaskCount) * 100;
|
||||
return (
|
||||
<YStack key={task.task_id} space="$1">
|
||||
@@ -237,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
})}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noTasks', 'No task activity yet')}
|
||||
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
@@ -245,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
function EmptyState({
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
const { muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center">
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{message}
|
||||
</Text>
|
||||
{actionLabel && onAction ? (
|
||||
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react';
|
||||
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Checkbox } from '@tamagui/checkbox';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
buildPackageComparisonRows,
|
||||
classifyPackageChange,
|
||||
getEnabledPackageFeatures,
|
||||
selectRecommendedPackageId,
|
||||
} from './lib/packageShop';
|
||||
import { usePackageCheckout } from './hooks/usePackageCheckout';
|
||||
|
||||
export default function MobilePackageShopPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme();
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
||||
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
|
||||
|
||||
// Extract recommended feature from URL
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -57,19 +63,36 @@ 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);
|
||||
|
||||
// Merge and sort packages
|
||||
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
||||
// 1. Recommended feature first
|
||||
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
|
||||
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature];
|
||||
if (aHasFeature && !bHasFeature) return -1;
|
||||
if (!aHasFeature && bHasFeature) return 1;
|
||||
if (recommendedPackageId) {
|
||||
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
|
||||
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
|
||||
}
|
||||
|
||||
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
|
||||
// Actually, let's keep price sorting as secondary
|
||||
return a.price - b.price;
|
||||
});
|
||||
|
||||
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 isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
||||
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
|
||||
|
||||
return {
|
||||
pkg,
|
||||
owned,
|
||||
isActive,
|
||||
isRecommended,
|
||||
isUpgrade,
|
||||
isDowngrade,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||
<YStack space="$4">
|
||||
@@ -93,23 +116,45 @@ export default function MobilePackageShopPage() {
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack space="$3">
|
||||
{sortedPackages.map((pkg) => {
|
||||
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature];
|
||||
{packageEntries.length > 1 ? (
|
||||
<XStack space="$2" paddingHorizontal="$2">
|
||||
<CTAButton
|
||||
label={t('shop.compare.toggleCards', 'Cards')}
|
||||
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => setViewMode('cards')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('shop.compare.toggleCompare', 'Compare')}
|
||||
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => setViewMode('compare')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<YStack space="$3">
|
||||
{viewMode === 'compare' ? (
|
||||
<PackageShopCompareView
|
||||
entries={packageEntries}
|
||||
onSelect={(pkg) => setSelectedPackage(pkg)}
|
||||
/>
|
||||
) : (
|
||||
packageEntries.map((entry) => (
|
||||
<PackageShopCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
owned={owned}
|
||||
isActive={isActive}
|
||||
isRecommended={isRecommended}
|
||||
onSelect={() => setSelectedPackage(pkg)}
|
||||
key={entry.pkg.id}
|
||||
pkg={entry.pkg}
|
||||
owned={entry.owned}
|
||||
isActive={entry.isActive}
|
||||
isRecommended={entry.isRecommended}
|
||||
isUpgrade={entry.isUpgrade}
|
||||
isDowngrade={entry.isDowngrade}
|
||||
onSelect={() => setSelectedPackage(entry.pkg)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
@@ -121,34 +166,34 @@ function PackageShopCard({
|
||||
owned,
|
||||
isActive,
|
||||
isRecommended,
|
||||
isUpgrade,
|
||||
isDowngrade,
|
||||
onSelect
|
||||
}: {
|
||||
pkg: Package;
|
||||
owned?: TenantPackageSummary;
|
||||
isActive?: boolean;
|
||||
isRecommended?: any;
|
||||
isUpgrade?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
|
||||
const statusLabel = isActive
|
||||
? t('shop.status.active', 'Active Plan')
|
||||
: owned
|
||||
? (owned.remaining_events !== null
|
||||
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
||||
: t('shop.status.owned', 'Purchased'))
|
||||
: null;
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
||||
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
|
||||
const canSelect = canSelectPackage(isUpgrade, isActive);
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
onPress={onSelect}
|
||||
onPress={canSelect ? onSelect : undefined}
|
||||
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
||||
borderWidth={isRecommended || isActive ? 2 : 1}
|
||||
space="$3"
|
||||
pressStyle={{ backgroundColor: accentSoft }}
|
||||
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
|
||||
backgroundColor={isActive ? '$green1' : undefined}
|
||||
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
||||
>
|
||||
<XStack justifyContent="space-between" alignItems="flex-start">
|
||||
<YStack space="$1">
|
||||
@@ -157,6 +202,8 @@ 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>}
|
||||
</XStack>
|
||||
|
||||
@@ -187,19 +234,25 @@ function PackageShopCard({
|
||||
) : null}
|
||||
|
||||
{/* Render specific feature if it was requested */}
|
||||
{Object.entries(pkg.features || {})
|
||||
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
|
||||
.slice(0, 3)
|
||||
.map(([key]) => (
|
||||
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
||||
))
|
||||
}
|
||||
{getEnabledPackageFeatures(pkg)
|
||||
.filter((key) => !pkg.max_photos || key !== 'photos')
|
||||
.slice(0, 3)
|
||||
.map((key) => (
|
||||
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<CTAButton
|
||||
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
|
||||
onPress={onSelect}
|
||||
tone={isActive ? 'ghost' : 'primary'}
|
||||
label={
|
||||
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'}
|
||||
disabled={!canSelect}
|
||||
/>
|
||||
</MobileCard>
|
||||
);
|
||||
@@ -215,28 +268,224 @@ function FeatureRow({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
type PackageEntry = {
|
||||
pkg: Package;
|
||||
owned?: TenantPackageSummary;
|
||||
isActive: boolean;
|
||||
isRecommended: boolean;
|
||||
isUpgrade: boolean;
|
||||
isDowngrade: boolean;
|
||||
};
|
||||
|
||||
function PackageShopCompareView({
|
||||
entries,
|
||||
onSelect,
|
||||
}: {
|
||||
entries: PackageEntry[];
|
||||
onSelect: (pkg: Package) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg));
|
||||
const labelWidth = 140;
|
||||
const columnWidth = 150;
|
||||
|
||||
const rows = [
|
||||
{ id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') },
|
||||
{ id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') },
|
||||
...comparisonRows,
|
||||
];
|
||||
|
||||
const renderRowLabel = (row: typeof rows[number]) => {
|
||||
if (row.type === 'meta') {
|
||||
return row.label;
|
||||
}
|
||||
|
||||
if (row.type === 'limit') {
|
||||
if (row.limitKey === 'max_photos') {
|
||||
return t('shop.compare.rows.photos', 'Photos');
|
||||
}
|
||||
if (row.limitKey === 'max_guests') {
|
||||
return t('shop.compare.rows.guests', 'Guests');
|
||||
}
|
||||
return t('shop.compare.rows.days', 'Gallery days');
|
||||
}
|
||||
|
||||
return t(`shop.features.${row.featureKey}`, row.featureKey);
|
||||
};
|
||||
|
||||
const formatLimitValue = (value: number | null) => {
|
||||
if (value === null) {
|
||||
return t('shop.compare.values.unlimited', 'Unlimited');
|
||||
}
|
||||
return new Intl.NumberFormat().format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileCard space="$3" borderColor={border}>
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
||||
{t('shop.compare.title', 'Compare plans')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<XStack style={{ overflowX: 'auto' }}>
|
||||
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
|
||||
{rows.map((row) => (
|
||||
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
||||
<YStack
|
||||
width={labelWidth}
|
||||
paddingVertical="$2"
|
||||
paddingRight="$3"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{renderRowLabel(row)}
|
||||
</Text>
|
||||
</YStack>
|
||||
{entries.map((entry) => {
|
||||
const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined;
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
if (row.type === 'meta') {
|
||||
if (row.id === 'meta.plan') {
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
|
||||
content = (
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{entry.pkg.name}
|
||||
</Text>
|
||||
<XStack space="$1.5" flexWrap="wrap">
|
||||
{entry.isRecommended ? (
|
||||
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isUpgrade && !entry.isActive ? (
|
||||
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
|
||||
) : null}
|
||||
{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}
|
||||
</XStack>
|
||||
{statusLabel ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
} else if (row.id === 'meta.price') {
|
||||
content = (
|
||||
<Text fontSize="$sm" fontWeight="700" color={primary}>
|
||||
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} else if (row.type === 'limit') {
|
||||
const value = entry.pkg[row.limitKey] ?? null;
|
||||
content = (
|
||||
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
||||
{formatLimitValue(value)}
|
||||
</Text>
|
||||
);
|
||||
} else if (row.type === 'feature') {
|
||||
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
|
||||
content = (
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
{enabled ? (
|
||||
<Check size={16} color={primary} />
|
||||
) : (
|
||||
<X size={14} color={muted} />
|
||||
)}
|
||||
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
|
||||
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
key={`${row.id}-${entry.pkg.id}`}
|
||||
width={columnWidth}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$2"
|
||||
justifyContent="center"
|
||||
backgroundColor={cellBackground}
|
||||
>
|
||||
{content}
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
))}
|
||||
<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');
|
||||
|
||||
return (
|
||||
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
|
||||
<CTAButton
|
||||
label={label}
|
||||
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
|
||||
disabled={!canSelect}
|
||||
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function getPackageStatusLabel({
|
||||
t,
|
||||
isActive,
|
||||
owned,
|
||||
}: {
|
||||
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
|
||||
isActive?: boolean;
|
||||
owned?: TenantPackageSummary;
|
||||
}): string | null {
|
||||
if (isActive) {
|
||||
return t('shop.status.active', 'Active Plan');
|
||||
}
|
||||
if (owned) {
|
||||
return owned.remaining_events !== null
|
||||
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
||||
: t('shop.status.owned', 'Purchased');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
|
||||
return Boolean(isActive || isUpgrade);
|
||||
}
|
||||
|
||||
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, danger } = useAdminTheme();
|
||||
const { textStrong, muted, border, primary } = useAdminTheme();
|
||||
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
||||
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const { busy, startCheckout } = usePackageCheckout();
|
||||
|
||||
const canProceed = agbAccepted && withdrawalAccepted;
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!canProceed || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
|
||||
success_url: window.location.href,
|
||||
return_url: window.location.href,
|
||||
});
|
||||
window.location.href = checkout_url;
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||
setBusy(false);
|
||||
}
|
||||
await startCheckout(pkg.id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveMaxCount, resolveTimelineHours } from '../lib/analytics';
|
||||
|
||||
describe('resolveMaxCount', () => {
|
||||
it('defaults to 1 for empty input', () => {
|
||||
expect(resolveMaxCount([])).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the highest count', () => {
|
||||
expect(resolveMaxCount([2, 5, 3])).toBe(5);
|
||||
});
|
||||
|
||||
it('never returns less than 1', () => {
|
||||
expect(resolveMaxCount([0])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineHours', () => {
|
||||
it('uses fallback when data is missing', () => {
|
||||
expect(resolveTimelineHours([], 12)).toBe(12);
|
||||
});
|
||||
|
||||
it('calculates rounded hours from timestamps', () => {
|
||||
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||
const end = new Date('2024-01-01T21:00:00Z').toISOString();
|
||||
expect(resolveTimelineHours([start, end], 12)).toBe(11);
|
||||
});
|
||||
|
||||
it('never returns less than 1', () => {
|
||||
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||
expect(resolveTimelineHours([start, start], 12)).toBe(1);
|
||||
});
|
||||
});
|
||||
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHECKOUT_STORAGE_KEY,
|
||||
PENDING_CHECKOUT_TTL_MS,
|
||||
isCheckoutExpired,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from '../lib/billingCheckout';
|
||||
|
||||
describe('billingCheckout helpers', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
it('detects expired pending checkout', () => {
|
||||
const pending = { packageId: 12, startedAt: 0 };
|
||||
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps pending checkout when active package differs', () => {
|
||||
const pending = { packageId: 12, startedAt: Date.now() };
|
||||
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears pending checkout when active package matches', () => {
|
||||
const now = Date.now();
|
||||
const pending = { packageId: 12, startedAt: now };
|
||||
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores and loads pending checkout from session storage', () => {
|
||||
const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() };
|
||||
storePendingCheckout(pending);
|
||||
expect(loadPendingCheckout(pending.startedAt)).toEqual(pending);
|
||||
});
|
||||
|
||||
it('clears pending checkout storage', () => {
|
||||
storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() });
|
||||
storePendingCheckout(null);
|
||||
expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPackageComparisonRows,
|
||||
classifyPackageChange,
|
||||
getEnabledPackageFeatures,
|
||||
selectRecommendedPackageId,
|
||||
} from '../lib/packageShop';
|
||||
|
||||
describe('classifyPackageChange', () => {
|
||||
const active = {
|
||||
id: 1,
|
||||
price: 200,
|
||||
max_photos: 100,
|
||||
max_guests: 50,
|
||||
gallery_days: 30,
|
||||
features: { advanced_analytics: false },
|
||||
} as any;
|
||||
|
||||
it('returns neutral when no active package', () => {
|
||||
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('marks upgrade when candidate adds features', () => {
|
||||
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('marks downgrade when candidate removes features or limits', () => {
|
||||
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
|
||||
it('treats mixed changes as downgrade', () => {
|
||||
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectRecommendedPackageId', () => {
|
||||
const packages = [
|
||||
{ id: 1, price: 100, features: { advanced_analytics: false } },
|
||||
{ id: 2, price: 150, features: { advanced_analytics: true } },
|
||||
{ id: 3, price: 200, features: { advanced_analytics: true } },
|
||||
] as any;
|
||||
|
||||
it('returns null when no feature is requested', () => {
|
||||
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('selects the cheapest upgrade with the feature', () => {
|
||||
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
|
||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to cheapest feature package if no upgrades exist', () => {
|
||||
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
|
||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPackageComparisonRows', () => {
|
||||
it('includes limit rows and enabled feature rows', () => {
|
||||
const rows = buildPackageComparisonRows([
|
||||
{ features: { advanced_analytics: true, custom_branding: false } },
|
||||
{ features: { custom_branding: true, watermark_removal: true } },
|
||||
] as any);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([
|
||||
'limit.max_photos',
|
||||
'limit.max_guests',
|
||||
'limit.gallery_days',
|
||||
'feature.advanced_analytics',
|
||||
'feature.custom_branding',
|
||||
'feature.watermark_removal',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnabledPackageFeatures', () => {
|
||||
it('accepts array payloads', () => {
|
||||
expect(getEnabledPackageFeatures({ features: ['custom_branding', ''] } as any)).toEqual(['custom_branding']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/core', () => ({
|
||||
useTheme: () => ({
|
||||
background: { val: '#FFF8F5' },
|
||||
surface: { val: '#ffffff' },
|
||||
borderColor: { val: '#e5e7eb' },
|
||||
color: { val: '#1f2937' },
|
||||
gray: { val: '#6b7280' },
|
||||
red10: { val: '#b91c1c' },
|
||||
shadowColor: { val: 'rgba(0,0,0,0.12)' },
|
||||
primary: { val: '#FF5A5F' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../BottomNav', () => ({
|
||||
BottomNav: () => <div data-testid="bottom-nav" />,
|
||||
NavKey: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../context/EventContext', () => ({
|
||||
useEventContext: () => ({
|
||||
events: [],
|
||||
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
||||
hasMultipleEvents: false,
|
||||
hasEvents: true,
|
||||
selectEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useMobileNav', () => ({
|
||||
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useNotificationsBadge', () => ({
|
||||
useNotificationsBadge: () => ({ count: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useOnlineStatus', () => ({
|
||||
useOnlineStatus: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/tabHistory', () => ({
|
||||
setTabHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/photoModerationQueue', () => ({
|
||||
loadPhotoQueue: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/queueStatus', () => ({
|
||||
countQueuedPhotoActions: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
background: '#FFF8F5',
|
||||
surface: '#ffffff',
|
||||
border: '#e5e7eb',
|
||||
text: '#1f2937',
|
||||
muted: '#6b7280',
|
||||
warningBg: '#fff7ed',
|
||||
warningText: '#92400e',
|
||||
primary: '#FF5A5F',
|
||||
danger: '#b91c1c',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MobileShell } from '../MobileShell';
|
||||
|
||||
describe('MobileShell', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders quick QR as icon-only button', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileShell activeTab="home">
|
||||
<div>Body</div>
|
||||
</MobileShell>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the event context on compact headers', async () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileShell activeTab="home">
|
||||
<div>Body</div>
|
||||
</MobileShell>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { createTenantPaddleCheckout } from '../../api';
|
||||
import { adminPath } from '../../constants';
|
||||
import { getApiErrorMessage } from '../../lib/apiError';
|
||||
import { storePendingCheckout } from '../lib/billingCheckout';
|
||||
|
||||
export function usePackageCheckout(): {
|
||||
busy: boolean;
|
||||
startCheckout: (packageId: number) => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation('management');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const startCheckout = React.useCallback(
|
||||
async (packageId: number) => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Checkout is only available in the browser.');
|
||||
}
|
||||
|
||||
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
|
||||
const successUrl = new URL(billingUrl);
|
||||
successUrl.searchParams.set('checkout', 'success');
|
||||
successUrl.searchParams.set('package_id', String(packageId));
|
||||
const cancelUrl = new URL(billingUrl);
|
||||
cancelUrl.searchParams.set('checkout', 'cancel');
|
||||
cancelUrl.searchParams.set('package_id', String(packageId));
|
||||
|
||||
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
|
||||
success_url: successUrl.toString(),
|
||||
return_url: cancelUrl.toString(),
|
||||
});
|
||||
|
||||
if (checkout_session_id) {
|
||||
storePendingCheckout({
|
||||
packageId,
|
||||
checkoutSessionId: checkout_session_id,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = checkout_url;
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, t],
|
||||
);
|
||||
|
||||
return { busy, startCheckout };
|
||||
}
|
||||
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function resolveMaxCount(values: number[]): number {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(...values, 1);
|
||||
}
|
||||
|
||||
export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number {
|
||||
if (!Array.isArray(timestamps) || timestamps.length < 2) {
|
||||
return fallbackHours;
|
||||
}
|
||||
|
||||
const times = timestamps
|
||||
.map((value) => new Date(value).getTime())
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (times.length < 2) {
|
||||
return fallbackHours;
|
||||
}
|
||||
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
const diff = Math.max(0, max - min);
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
return Math.max(1, Math.round(hours));
|
||||
}
|
||||
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type PendingCheckout = {
|
||||
packageId: number | null;
|
||||
checkoutSessionId?: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
|
||||
export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
|
||||
|
||||
export function isCheckoutExpired(
|
||||
pending: PendingCheckout,
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): boolean {
|
||||
return now - pending.startedAt > ttl;
|
||||
}
|
||||
|
||||
export function loadPendingCheckout(
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): PendingCheckout | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
|
||||
if (! raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PendingCheckout;
|
||||
if (typeof parsed?.startedAt !== 'number') {
|
||||
return null;
|
||||
}
|
||||
const packageId =
|
||||
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
|
||||
? parsed.packageId
|
||||
: null;
|
||||
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
|
||||
if (now - parsed.startedAt > ttl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
packageId,
|
||||
checkoutSessionId,
|
||||
startedAt: parsed.startedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storePendingCheckout(next: PendingCheckout | null): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (! next) {
|
||||
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
|
||||
} else {
|
||||
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldClearPendingCheckout(
|
||||
pending: PendingCheckout,
|
||||
activePackageId: number | null,
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): boolean {
|
||||
if (isCheckoutExpired(pending, now, ttl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Package } from '../../api';
|
||||
|
||||
type PackageChange = {
|
||||
isUpgrade: boolean;
|
||||
isDowngrade: boolean;
|
||||
};
|
||||
|
||||
export type PackageComparisonRow =
|
||||
| {
|
||||
id: string;
|
||||
type: 'limit';
|
||||
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'feature';
|
||||
featureKey: string;
|
||||
};
|
||||
|
||||
function normalizePackageFeatures(pkg: Package | null): string[] {
|
||||
if (!pkg?.features) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(pkg.features)) {
|
||||
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
|
||||
}
|
||||
|
||||
if (typeof pkg.features === 'object') {
|
||||
return Object.entries(pkg.features)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getEnabledPackageFeatures(pkg: Package): string[] {
|
||||
return normalizePackageFeatures(pkg);
|
||||
}
|
||||
|
||||
function collectFeatures(pkg: Package | null): Set<string> {
|
||||
return new Set(normalizePackageFeatures(pkg));
|
||||
}
|
||||
|
||||
function compareLimit(candidate: number | null, active: number | null): number {
|
||||
if (active === null) {
|
||||
return candidate === null ? 0 : -1;
|
||||
}
|
||||
|
||||
if (candidate === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (candidate > active) return 1;
|
||||
if (candidate < active) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
|
||||
if (!active) {
|
||||
return { isUpgrade: false, isDowngrade: false };
|
||||
}
|
||||
|
||||
const activeFeatures = collectFeatures(active);
|
||||
const candidateFeatures = collectFeatures(pkg);
|
||||
|
||||
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
|
||||
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
|
||||
|
||||
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||
let hasLimitUpgrade = false;
|
||||
let hasLimitDowngrade = false;
|
||||
|
||||
limitKeys.forEach((key) => {
|
||||
const candidateLimit = pkg[key] ?? null;
|
||||
const activeLimit = active[key] ?? null;
|
||||
const delta = compareLimit(candidateLimit, activeLimit);
|
||||
if (delta > 0) {
|
||||
hasLimitUpgrade = true;
|
||||
} else if (delta < 0) {
|
||||
hasLimitDowngrade = true;
|
||||
}
|
||||
});
|
||||
|
||||
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
|
||||
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
|
||||
|
||||
if (hasUpgrade && !hasDowngrade) {
|
||||
return { isUpgrade: true, isDowngrade: false };
|
||||
}
|
||||
|
||||
if (hasDowngrade) {
|
||||
return { isUpgrade: false, isDowngrade: true };
|
||||
}
|
||||
|
||||
return { isUpgrade: false, isDowngrade: false };
|
||||
}
|
||||
|
||||
export function selectRecommendedPackageId(
|
||||
packages: Package[],
|
||||
feature: string | null,
|
||||
activePackage: Package | null
|
||||
): number | null {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
|
||||
const pool = upgrades.length ? upgrades : candidates;
|
||||
const sorted = [...pool].sort((a, b) => a.price - b.price);
|
||||
|
||||
return sorted[0]?.id ?? null;
|
||||
}
|
||||
|
||||
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 featureKeys = new Set<string>();
|
||||
packages.forEach((pkg) => {
|
||||
normalizePackageFeatures(pkg).forEach((key) => {
|
||||
if (key !== 'photos') {
|
||||
featureKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const featureRows = Array.from(featureKeys)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((featureKey) => ({
|
||||
id: `feature.${featureKey}`,
|
||||
type: 'feature' as const,
|
||||
featureKey,
|
||||
}));
|
||||
|
||||
return [...limitRows, ...featureRows];
|
||||
}
|
||||
@@ -15,7 +15,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
|
||||
return template
|
||||
.replace('{{used}}', String(options?.used ?? '{{used}}'))
|
||||
.replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
|
||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
|
||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'))
|
||||
.replace('{{count}}', String(options?.count ?? '{{count}}'));
|
||||
};
|
||||
|
||||
describe('packageSummary helpers', () => {
|
||||
@@ -53,6 +54,12 @@ describe('packageSummary helpers', () => {
|
||||
expect(result[0].value).toBe('30 of 120 remaining');
|
||||
});
|
||||
|
||||
it('falls back to remaining count when remaining exceeds limit', () => {
|
||||
const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 180 }, t);
|
||||
|
||||
expect(result[0].value).toBe('Remaining 180');
|
||||
});
|
||||
|
||||
it('formats event usage copy', () => {
|
||||
const result = formatEventUsage(3, 10, t);
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null
|
||||
|
||||
if (remaining !== null && remaining >= 0) {
|
||||
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
|
||||
if (normalizedRemaining > limit) {
|
||||
return t('mobileBilling.usage.remaining', {
|
||||
count: normalizedRemaining,
|
||||
defaultValue: 'Remaining {{count}}',
|
||||
});
|
||||
}
|
||||
return t('mobileBilling.usage.remainingOf', {
|
||||
remaining: normalizedRemaining,
|
||||
limit,
|
||||
|
||||
@@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||
import { isTaskModeEnabled } from '../lib/engagement';
|
||||
@@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const { event, status } = useEventData();
|
||||
const notificationCenter = useOptionalNotificationCenter();
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const taskProgress = useGuestTaskProgress(eventToken);
|
||||
const tasksEnabled = isTaskModeEnabled(event);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
@@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||
panelRef={panelRef}
|
||||
buttonRef={notificationButtonRef}
|
||||
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -285,18 +282,14 @@ type NotificationButtonProps = {
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
|
||||
const pushState = usePushSubscription(eventToken);
|
||||
|
||||
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
case 'unread':
|
||||
base = unreadNotifications;
|
||||
break;
|
||||
case 'status':
|
||||
case 'uploads':
|
||||
base = uploadNotifications;
|
||||
break;
|
||||
default:
|
||||
@@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
||||
|
||||
const scopedNotifications = React.useMemo(() => {
|
||||
if (scopeFilter === 'all') {
|
||||
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
||||
return filteredNotifications;
|
||||
}
|
||||
return filteredNotifications.filter((item) => {
|
||||
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{center.unreadCount > 0
|
||||
? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount })
|
||||
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
|
||||
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
</div>
|
||||
<NotificationTabs
|
||||
tabs={[
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
|
||||
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
|
||||
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
|
||||
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
|
||||
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeFilter(option.key);
|
||||
center.setFilters({ scope: option.key });
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 font-semibold transition ${
|
||||
scopeFilter === option.key
|
||||
? 'border-pink-200 bg-pink-50 text-pink-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{activeTab !== 'uploads' && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
|
||||
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
|
||||
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeFilter(option.key);
|
||||
center.setFilters({ scope: option.key });
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 font-semibold transition ${
|
||||
scopeFilter === option.key
|
||||
? 'border-pink-200 bg-pink-50 text-pink-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'status'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'status' && (
|
||||
)}
|
||||
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{center.pendingCount > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
|
||||
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{taskProgress && (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
{t('header.notifications.tasksCta', 'Weiter')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-pink-500"
|
||||
style={{ width: `${progressRatio * 100}%` }}
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'uploads'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<NotificationStatusBar
|
||||
lastFetchedAt={center.lastFetchedAt}
|
||||
isOffline={center.isOffline}
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('../../context/NotificationCenterContext', () => ({
|
||||
queueItems: [],
|
||||
queueCount: 0,
|
||||
pendingCount: 0,
|
||||
totalCount: 0,
|
||||
loading: false,
|
||||
pendingLoading: false,
|
||||
refresh: vi.fn(),
|
||||
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
|
||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
pendingCount: number;
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
pendingLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||
const totalCount = unreadCount + queueCount + pendingCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
void updateAppBadge(totalCount);
|
||||
}, [totalCount]);
|
||||
void updateAppBadge(unreadCount);
|
||||
}, [unreadCount]);
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
pendingCount,
|
||||
totalCount,
|
||||
loading,
|
||||
pendingLoading,
|
||||
refresh,
|
||||
|
||||
@@ -42,7 +42,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
helpGallery: 'Hilfe zu Galerie & Teilen',
|
||||
notifications: {
|
||||
tabStatus: 'Upload-Status',
|
||||
title: 'Updates',
|
||||
unread: '{count} neu',
|
||||
allRead: 'Alles gelesen',
|
||||
tabUnread: 'Nachrichten',
|
||||
tabUploads: 'Uploads',
|
||||
tabAll: 'Alle Updates',
|
||||
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
|
||||
},
|
||||
},
|
||||
liveShowPlayer: {
|
||||
@@ -774,7 +780,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
helpGallery: 'Help: Gallery & sharing',
|
||||
notifications: {
|
||||
tabStatus: 'Upload status',
|
||||
title: 'Updates',
|
||||
unread: '{count} new',
|
||||
allRead: 'All read',
|
||||
tabUnread: 'Messages',
|
||||
tabUploads: 'Uploads',
|
||||
tabAll: 'All updates',
|
||||
emptyStatus: 'No upload status or maintenance active.',
|
||||
},
|
||||
},
|
||||
liveShowPlayer: {
|
||||
|
||||
Reference in New Issue
Block a user