Fix PayPal billing flow and mobile admin UX
This commit is contained in:
@@ -571,6 +571,19 @@ export type LemonSqueezyOrderSummary = {
|
||||
tax?: number | null;
|
||||
};
|
||||
|
||||
export type TenantBillingTransactionSummary = {
|
||||
id: string | number | null;
|
||||
status: string | null;
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
provider: string | null;
|
||||
provider_id: string | null;
|
||||
package_name: string | null;
|
||||
purchased_at: string | null;
|
||||
receipt_url?: string | null;
|
||||
tax?: number | null;
|
||||
};
|
||||
|
||||
export type TenantAddonEventSummary = {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -1125,21 +1138,21 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary {
|
||||
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
|
||||
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
|
||||
function normalizeTenantBillingTransaction(entry: JsonValue): TenantBillingTransactionSummary {
|
||||
const idValue = (entry as { id?: unknown }).id;
|
||||
const amountValue = (entry as { amount?: unknown }).amount;
|
||||
const taxValue = (entry as { tax?: unknown }).tax;
|
||||
|
||||
return {
|
||||
id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null,
|
||||
status: entry.status ?? null,
|
||||
id: typeof idValue === 'string' || typeof idValue === 'number' ? idValue : idValue ? String(idValue) : null,
|
||||
status: typeof (entry as { status?: unknown }).status === 'string' ? (entry as { status?: unknown }).status : null,
|
||||
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
||||
currency: entry.currency ?? entry.currency_code ?? 'EUR',
|
||||
origin: entry.origin ?? null,
|
||||
checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null),
|
||||
created_at: entry.created_at ?? null,
|
||||
updated_at: entry.updated_at ?? null,
|
||||
receipt_url: entry.receipt_url ?? entry.invoice_url ?? null,
|
||||
grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null,
|
||||
currency: typeof (entry as { currency?: unknown }).currency === 'string' ? (entry as { currency?: unknown }).currency : 'EUR',
|
||||
provider: typeof (entry as { provider?: unknown }).provider === 'string' ? (entry as { provider?: unknown }).provider : null,
|
||||
provider_id: typeof (entry as { provider_id?: unknown }).provider_id === 'string' ? (entry as { provider_id?: unknown }).provider_id : null,
|
||||
package_name: typeof (entry as { package_name?: unknown }).package_name === 'string' ? (entry as { package_name?: unknown }).package_name : null,
|
||||
purchased_at: typeof (entry as { purchased_at?: unknown }).purchased_at === 'string' ? (entry as { purchased_at?: unknown }).purchased_at : null,
|
||||
receipt_url: typeof (entry as { receipt_url?: unknown }).receipt_url === 'string' ? (entry as { receipt_url?: unknown }).receipt_url : null,
|
||||
tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null,
|
||||
};
|
||||
}
|
||||
@@ -2731,32 +2744,43 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise<Blo
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getTenantLemonSqueezyTransactions(cursor?: string): Promise<{
|
||||
data: LemonSqueezyOrderSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
export async function downloadTenantBillingReceipt(receiptUrl: string): Promise<Blob> {
|
||||
const response = await authorizedFetch(receiptUrl, {
|
||||
headers: { 'Accept': 'application/pdf' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to download billing receipt', response.status, payload);
|
||||
throw new Error('Failed to download billing receipt');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getTenantBillingTransactions(page = 1): Promise<{
|
||||
data: TenantBillingTransactionSummary[];
|
||||
}> {
|
||||
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
|
||||
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`);
|
||||
const params = new URLSearchParams({
|
||||
page: String(Math.max(1, page)),
|
||||
});
|
||||
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions?${params.toString()}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { data: [], nextCursor: null, hasMore: false };
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload);
|
||||
throw new Error('Failed to load Lemon Squeezy transactions');
|
||||
console.error('[API] Failed to load billing transactions', response.status, payload);
|
||||
throw new Error('Failed to load billing transactions');
|
||||
}
|
||||
|
||||
const payload = await safeJson(response) ?? {};
|
||||
const entries = Array.isArray(payload.data) ? payload.data : [];
|
||||
const meta = payload.meta ?? {};
|
||||
|
||||
return {
|
||||
data: entries.map(normalizeLemonSqueezyOrder),
|
||||
nextCursor: typeof meta.next === 'string' ? meta.next : null,
|
||||
hasMore: Boolean(meta.has_more),
|
||||
data: entries.map(normalizeTenantBillingTransaction),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"language": "Sprache",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "Englisch",
|
||||
"theme": "Theme",
|
||||
"theme": "Erscheinungsbild",
|
||||
"themeLight": "Hell",
|
||||
"themeDark": "Dunkel",
|
||||
"themeSystem": "System",
|
||||
"themeSystemLabel": "System ({{mode}})",
|
||||
"themeSystemHint": "Folgt der Geräteeinstellung: {{mode}}",
|
||||
"logout": "Abmelden",
|
||||
"logoutTitle": "Ausloggen",
|
||||
"logoutHint": "Aus der App ausloggen"
|
||||
@@ -27,8 +29,9 @@
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV",
|
||||
"portal": "Im PayPal-Portal verwalten",
|
||||
"portalBusy": "Portal wird geöffnet...",
|
||||
"portal": "Abrechnungsdetails",
|
||||
"portalBusy": "Details werden geöffnet...",
|
||||
"receiptDownloaded": "Beleg heruntergeladen.",
|
||||
"openPackages": "Pakete öffnen",
|
||||
"contactSupport": "Support kontaktieren"
|
||||
},
|
||||
@@ -54,7 +57,8 @@
|
||||
"errors": {
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||
"portal": "PayPal-Portal konnte nicht geöffnet werden."
|
||||
"portal": "Abrechnungsdetails konnten nicht geöffnet werden.",
|
||||
"receipt": "Beleg konnte nicht heruntergeladen werden."
|
||||
},
|
||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||
@@ -140,14 +144,14 @@
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"title": "PayPal-Transaktionen",
|
||||
"description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.",
|
||||
"empty": "Noch keine PayPal-Transaktionen.",
|
||||
"title": "Transaktionen",
|
||||
"description": "Neueste Paketkäufe für dieses Kundenkonto.",
|
||||
"empty": "Noch keine Transaktionen.",
|
||||
"labels": {
|
||||
"transactionId": "Transaktion {{id}}",
|
||||
"checkoutId": "Checkout-ID: {{id}}",
|
||||
"origin": "Herkunft: {{origin}}",
|
||||
"receipt": "Beleg ansehen",
|
||||
"provider": "Anbieter: {{provider}}",
|
||||
"receipt": "Beleg herunterladen",
|
||||
"packageFallback": "Paket",
|
||||
"tax": "Steuer: {{value}}"
|
||||
},
|
||||
"table": {
|
||||
@@ -162,6 +166,7 @@
|
||||
"processing": "Verarbeitung",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"cancelled": "Storniert",
|
||||
"refunded": "Erstattet",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"loadMore": "Weitere Transaktionen laden",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"language": "Language",
|
||||
"languageDe": "Deutsch",
|
||||
"languageEn": "English",
|
||||
"theme": "Theme",
|
||||
"theme": "Appearance",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"themeSystemLabel": "System ({{mode}})",
|
||||
"themeSystemHint": "Following device setting: {{mode}}",
|
||||
"logout": "Log out",
|
||||
"logoutTitle": "Sign out",
|
||||
"logoutHint": "Sign out from this app."
|
||||
@@ -27,8 +29,9 @@
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV",
|
||||
"portal": "Manage in PayPal",
|
||||
"portalBusy": "Opening portal...",
|
||||
"portal": "Billing details",
|
||||
"portalBusy": "Opening billing details...",
|
||||
"receiptDownloaded": "Receipt downloaded.",
|
||||
"openPackages": "Open packages",
|
||||
"contactSupport": "Contact support"
|
||||
},
|
||||
@@ -54,7 +57,8 @@
|
||||
"errors": {
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries.",
|
||||
"portal": "Unable to open the PayPal portal."
|
||||
"portal": "Unable to open billing details.",
|
||||
"receipt": "Receipt download failed."
|
||||
},
|
||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||
"checkoutCancelled": "Checkout was cancelled.",
|
||||
@@ -140,14 +144,14 @@
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
"title": "PayPal transactions",
|
||||
"description": "Recent PayPal transactions for this customer account.",
|
||||
"empty": "No PayPal transactions yet.",
|
||||
"title": "Transactions",
|
||||
"description": "Recent package purchases for this account.",
|
||||
"empty": "No transactions yet.",
|
||||
"labels": {
|
||||
"transactionId": "Transaction {{id}}",
|
||||
"checkoutId": "Checkout ID: {{id}}",
|
||||
"origin": "Origin: {{origin}}",
|
||||
"receipt": "View receipt",
|
||||
"provider": "Provider: {{provider}}",
|
||||
"receipt": "Download receipt",
|
||||
"packageFallback": "Package",
|
||||
"tax": "Tax: {{value}}"
|
||||
},
|
||||
"table": {
|
||||
@@ -162,6 +166,7 @@
|
||||
"processing": "Processing",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"refunded": "Refunded",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"loadMore": "Load more transactions",
|
||||
|
||||
@@ -10,12 +10,12 @@ import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { ContextHelpLink } from './components/ContextHelpLink';
|
||||
import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantBillingTransactions,
|
||||
getTenantPackagesOverview,
|
||||
getTenantLemonSqueezyTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
LemonSqueezyOrderSummary,
|
||||
TenantBillingTransactionSummary,
|
||||
downloadTenantBillingReceipt,
|
||||
} from '../api';
|
||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
@@ -52,11 +53,10 @@ export default function MobileBillingPage() {
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [transactions, setTransactions] = React.useState<LemonSqueezyOrderSummary[]>([]);
|
||||
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
|
||||
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||
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);
|
||||
@@ -78,7 +78,7 @@ export default function MobileBillingPage() {
|
||||
try {
|
||||
const [pkg, trx, addonHistory] = await Promise.all([
|
||||
getTenantPackagesOverview({ force: true }),
|
||||
getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })),
|
||||
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
|
||||
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
||||
]);
|
||||
setPackages(pkg.packages ?? []);
|
||||
@@ -104,30 +104,32 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [supportEmail]);
|
||||
|
||||
const openPortal = React.useCallback(async () => {
|
||||
if (portalBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPortalBusy(true);
|
||||
try {
|
||||
const { url } = await createTenantBillingPortalSession();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.'));
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPortalBusy(false);
|
||||
}
|
||||
}, [portalBusy, t]);
|
||||
|
||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||
setPendingCheckout(next);
|
||||
storePendingCheckout(next);
|
||||
}, []);
|
||||
|
||||
const handleReceiptDownload = React.useCallback(
|
||||
async (transaction: TenantBillingTransactionSummary) => {
|
||||
if (!transaction.receipt_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionId = transaction.provider_id
|
||||
?? (transaction.id !== null && transaction.id !== undefined ? String(transaction.id) : 'receipt');
|
||||
|
||||
try {
|
||||
const blob = await downloadTenantBillingReceipt(transaction.receipt_url);
|
||||
const filename = `fotospiel-receipt-${transactionId}.pdf`;
|
||||
triggerDownloadFromBlob(blob, filename);
|
||||
toast.success(t('billing.actions.receiptDownloaded', 'Receipt downloaded.'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('billing.errors.receipt', 'Receipt download failed.')));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -387,11 +389,6 @@ export default function MobileBillingPage() {
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Lemon Squeezy')}
|
||||
onPress={openPortal}
|
||||
disabled={portalBusy}
|
||||
/>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('common.loading', 'Lädt...')}
|
||||
@@ -439,38 +436,55 @@ export default function MobileBillingPage() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1.5">
|
||||
{transactions.slice(0, 8).map((trx) => (
|
||||
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{trx.status ?? '—'}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(trx.created_at)}
|
||||
</Text>
|
||||
{trx.origin ? (
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{trx.origin}
|
||||
{transactions.slice(0, 8).map((trx) => {
|
||||
const statusLabel = trx.status
|
||||
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
|
||||
: '—';
|
||||
const providerLabel = trx.provider
|
||||
? t(`billing.providers.${trx.provider}`, trx.provider)
|
||||
: t('billing.providers.unknown', 'Unknown');
|
||||
const transactionId = trx.provider_id ?? (trx.id !== null && trx.id !== undefined ? String(trx.id) : '—');
|
||||
const packageName = trx.package_name ?? t('billing.sections.transactions.labels.packageFallback', 'Package');
|
||||
|
||||
return (
|
||||
<XStack key={trx.id ?? transactionId} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{packageName}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<YStack alignItems="flex-end">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{formatAmount(trx.amount, trx.currency)}
|
||||
</Text>
|
||||
{trx.tax ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
||||
{statusLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{trx.receipt_url ? (
|
||||
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
|
||||
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
||||
</a>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{t('billing.sections.transactions.labels.provider', { provider: providerLabel })}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{t('billing.sections.transactions.labels.transactionId', { id: transactionId })}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack alignItems="flex-end">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{formatAmount(trx.amount, trx.currency)}
|
||||
</Text>
|
||||
{trx.tax ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(trx.purchased_at)}
|
||||
</Text>
|
||||
{trx.receipt_url ? (
|
||||
<Pressable onPress={() => void handleReceiptDownload(trx)}>
|
||||
<Text fontSize="$xs" color={primary}>
|
||||
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
181
resources/js/admin/mobile/__tests__/BillingPage.test.tsx
Normal file
181
resources/js/admin/mobile/__tests__/BillingPage.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
const tMock = (
|
||||
_key: string,
|
||||
fallback?: string | Record<string, unknown>,
|
||||
options?: Record<string, unknown>,
|
||||
) => {
|
||||
let value = typeof fallback === 'string' ? fallback : _key;
|
||||
if (options) {
|
||||
Object.entries(options).forEach(([key, val]) => {
|
||||
value = value.replaceAll(`{{${key}}}`, String(val));
|
||||
});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const downloadReceiptMock = vi.fn().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }));
|
||||
const triggerDownloadMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useLocation: () => ({ pathname: '/mobile/billing', search: '', hash: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: tMock }),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Package: () => <span />,
|
||||
Receipt: () => <span />,
|
||||
RefreshCcw: () => <span />,
|
||||
Sparkles: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => {
|
||||
const toast = Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
});
|
||||
return { default: toast };
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
textStrong: '#111827',
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#9ca3af',
|
||||
danger: '#b91c1c',
|
||||
border: '#e5e7eb',
|
||||
primary: '#2563eb',
|
||||
accentSoft: '#eef2ff',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ContextHelpLink', () => ({
|
||||
ContextHelpLink: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({
|
||||
children,
|
||||
onPress,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
}) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
vi.mock('../../constants', () => ({
|
||||
ADMIN_EVENT_VIEW_PATH: '/mobile/events',
|
||||
adminPath: (path: string) => path,
|
||||
}));
|
||||
|
||||
vi.mock('../billingUsage', () => ({
|
||||
buildPackageUsageMetrics: () => [],
|
||||
formatPackageEventAllowance: () => '—',
|
||||
getUsageState: () => 'ok',
|
||||
usagePercent: () => 0,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/packageSummary', () => ({
|
||||
collectPackageFeatures: () => [],
|
||||
formatEventUsage: () => '',
|
||||
getPackageFeatureLabel: () => '',
|
||||
getPackageLimitEntries: () => [],
|
||||
resolveTenantWatermarkFeatureKey: () => '',
|
||||
}));
|
||||
|
||||
vi.mock('../lib/billingCheckout', () => ({
|
||||
loadPendingCheckout: () => null,
|
||||
shouldClearPendingCheckout: () => false,
|
||||
storePendingCheckout: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../invite-layout/export-utils', () => ({
|
||||
triggerDownloadFromBlob: (...args: unknown[]) => triggerDownloadMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
|
||||
getTenantBillingTransactions: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
amount: 49,
|
||||
currency: 'EUR',
|
||||
provider: 'paypal',
|
||||
provider_id: 'ORDER-1',
|
||||
package_name: 'Starter',
|
||||
purchased_at: '2024-01-01T00:00:00Z',
|
||||
receipt_url: '/api/v1/billing/transactions/1/receipt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
getTenantAddonHistory: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getTenantPackageCheckoutStatus: vi.fn(),
|
||||
downloadTenantBillingReceipt: (...args: unknown[]) => downloadReceiptMock(...args),
|
||||
}));
|
||||
|
||||
import MobileBillingPage from '../BillingPage';
|
||||
|
||||
describe('MobileBillingPage', () => {
|
||||
it('downloads receipts via the API helper', async () => {
|
||||
render(<MobileBillingPage />);
|
||||
|
||||
const receiptLink = await screen.findByText('Beleg');
|
||||
fireEvent.click(receiptLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadReceiptMock).toHaveBeenCalledWith('/api/v1/billing/transactions/1/receipt');
|
||||
expect(triggerDownloadMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
13
resources/js/admin/mobile/__tests__/packageSummary.test.ts
Normal file
13
resources/js/admin/mobile/__tests__/packageSummary.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPackageLimitEntries } from '../lib/packageSummary';
|
||||
|
||||
const t = (_key: string, fallback?: string) => fallback ?? _key;
|
||||
|
||||
describe('getPackageLimitEntries', () => {
|
||||
it('defaults endcustomer event limit to 1 when missing', () => {
|
||||
const entries = getPackageLimitEntries({}, t, {}, { packageType: 'endcustomer' });
|
||||
const eventEntry = entries.find((entry) => entry.key === 'max_events_per_year');
|
||||
|
||||
expect(eventEntry?.value).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
const { t } = useTranslation('mobile');
|
||||
const location = useLocation();
|
||||
const theme = useAdminTheme();
|
||||
const navRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Modern Glass Background
|
||||
const navSurface = theme.glassSurfaceStrong ?? theme.surfaceMuted ?? theme.surface;
|
||||
@@ -35,8 +36,46 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
|
||||
];
|
||||
|
||||
const setBottomOffset = React.useCallback(() => {
|
||||
if (typeof document === 'undefined' || !navRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const height = Math.ceil(navRef.current.getBoundingClientRect().height);
|
||||
document.documentElement.style.setProperty('--admin-bottom-nav-offset', `${height}px`);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBottomOffset();
|
||||
|
||||
const handleResize = () => setBottomOffset();
|
||||
if (typeof ResizeObserver !== 'undefined' && navRef.current) {
|
||||
const observer = new ResizeObserver(() => setBottomOffset());
|
||||
observer.observe(navRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
};
|
||||
}, [setBottomOffset]);
|
||||
|
||||
return (
|
||||
<YStack
|
||||
ref={navRef as any}
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING, NavKey } from './BottomNav';
|
||||
import { BottomNav, NavKey } from './BottomNav';
|
||||
import { useMobileNav } from '../hooks/useMobileNav';
|
||||
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
|
||||
import { MobileCard, CTAButton } from './Primitives';
|
||||
@@ -302,6 +302,9 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
backgroundColor={backgroundColor}
|
||||
minHeight="100vh"
|
||||
alignItems="center"
|
||||
style={{
|
||||
scrollPaddingBottom: 'var(--admin-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 78px))',
|
||||
}}
|
||||
>
|
||||
<YStack
|
||||
backgroundColor={headerSurface}
|
||||
@@ -331,13 +334,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
flex={1}
|
||||
padding="$4"
|
||||
gap="$3"
|
||||
width="100%"
|
||||
maxWidth={800}
|
||||
style={{
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING}px)`,
|
||||
paddingBottom: 'calc(var(--admin-bottom-nav-offset, calc(env(safe-area-inset-bottom, 0px) + 78px)) + 16px)',
|
||||
}}
|
||||
>
|
||||
{!online ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, CreditCard, FileText, HelpCircle, Moon, Sun, User, X } from 'lucide-react';
|
||||
import { ChevronRight, CreditCard, FileText, HelpCircle, Monitor, Moon, Sun, User, X } from 'lucide-react';
|
||||
import { XStack, YStack, SizableText as Text, ListItem, YGroup, Separator } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
@@ -32,6 +32,12 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
}, [i18n.language]);
|
||||
|
||||
const themeValue: 'light' | 'dark' = (appearance === 'system' ? resolved : appearance) ?? 'light';
|
||||
const selectedAppearance: 'light' | 'dark' | 'system' = appearance ?? 'system';
|
||||
const resolvedLabel =
|
||||
themeValue === 'dark'
|
||||
? t('mobileProfile.themeDark', 'Dark')
|
||||
: t('mobileProfile.themeLight', 'Light');
|
||||
const systemLabel = t('mobileProfile.themeSystemLabel', 'System ({{mode}})', { mode: resolvedLabel });
|
||||
const activeToggleBg = theme.accentSoft ?? withAlpha(theme.primary, 0.18);
|
||||
const activeToggleBorder = withAlpha(theme.primary, 0.45);
|
||||
|
||||
@@ -244,9 +250,9 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
iconAfter={
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={themeValue}
|
||||
value={selectedAppearance}
|
||||
onValueChange={(next: string) => {
|
||||
if (next === 'light' || next === 'dark') {
|
||||
if (next === 'light' || next === 'dark' || next === 'system') {
|
||||
updateAppearance(next);
|
||||
}
|
||||
}}
|
||||
@@ -258,25 +264,24 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
{([
|
||||
{ key: 'light', label: t('mobileProfile.themeLight', 'Light'), icon: Sun },
|
||||
{ key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon },
|
||||
{ key: 'system', label: systemLabel, icon: Monitor },
|
||||
] as const).map((option) => {
|
||||
const active = option.key === themeValue;
|
||||
const active = option.key === selectedAppearance;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.key}
|
||||
value={option.key}
|
||||
aria-label={option.label}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeToggleBorder : theme.border}
|
||||
backgroundColor={active ? activeToggleBg : 'transparent'}
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1.5"
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$1"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Icon size={14} color={active ? theme.textStrong : theme.muted} />
|
||||
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? theme.textStrong : theme.muted}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<XStack alignItems="center" justifyContent="center" minWidth={28} minHeight={24}>
|
||||
<Icon size={16} color={active ? theme.textStrong : theme.muted} />
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
@@ -288,7 +293,7 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
</YGroup>
|
||||
{appearance === 'system' ? (
|
||||
<Text fontSize="$xs" color={theme.muted}>
|
||||
{t('mobileProfile.themeSystem', 'System')}
|
||||
{t('mobileProfile.themeSystemHint', 'Following device setting: {{mode}}', { mode: resolvedLabel })}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
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('../../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
primary: '#FF5A5F',
|
||||
muted: '#6b7280',
|
||||
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
|
||||
surfaceMuted: '#f8fafc',
|
||||
surface: '#ffffff',
|
||||
glassBorder: 'rgba(229,231,235,0.7)',
|
||||
border: '#e5e7eb',
|
||||
glassShadow: 'rgba(15,23,42,0.14)',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../constants', () => ({
|
||||
adminPath: (path: string) => path,
|
||||
}));
|
||||
|
||||
import { BottomNav } from '../BottomNav';
|
||||
|
||||
describe('BottomNav', () => {
|
||||
beforeEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 84,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 84,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class {
|
||||
private callback: () => void;
|
||||
|
||||
constructor(callback: () => void) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
observe() {
|
||||
this.callback();
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
document.documentElement.style.removeProperty('--admin-bottom-nav-offset');
|
||||
if (originalResizeObserver) {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
} else {
|
||||
delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the admin bottom nav offset CSS variable', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/mobile/profile']}>
|
||||
<BottomNav active="profile" onNavigate={() => undefined} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--admin-bottom-nav-offset')).toBe('84px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,13 @@ export function getPackageLimitEntries(
|
||||
key,
|
||||
label: t(labelKey, fallback),
|
||||
value: formatLimitWithRemaining(
|
||||
toNumber((limits as Record<string, number | null>)[key]),
|
||||
(() => {
|
||||
const limitValue = toNumber((limits as Record<string, number | null>)[key]);
|
||||
if (key === 'max_events_per_year' && options.packageType !== 'reseller' && limitValue === null) {
|
||||
return 1;
|
||||
}
|
||||
return limitValue;
|
||||
})(),
|
||||
resolveRemainingForKey(limits, key, usageOverrides),
|
||||
t
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user