frontend_nyla/src/pages/billing/SubscriptionTab.tsx
2026-04-12 14:05:01 +02:00

623 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SubscriptionTab — State-machine-aligned subscription management UI.
*
* Shows:
* - Current operative subscription with status, recurring flag, and period info
* - Scheduled successor (if plan switch in progress)
* - Available plans as cards
* - ID-based actions: cancel, reactivate, activate
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSubscription } from '../../hooks/useSubscription';
import { useConfirm } from '../../hooks/useConfirm';
import type { SubscriptionPlan, MandateSubscription, SubscriptionUsage } from '../../api/subscriptionApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================
// Helpers
// ============================================================================
const _formatCurrency = (amount: number) =>
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
const _formatDate = (iso: string | null | undefined): string => {
if (!iso) return '—';
try {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return iso;
}
};
function _getStatusMap(t: (k: string) => string): Record<string, { label: string; color: string }> {
return {
PENDING: { label: t('Zahlung ausstehend'), color: '#f59e0b' },
SCHEDULED: { label: t('Geplant'), color: '#8b5cf6' },
ACTIVE: { label: t('Aktiv'), color: '#22c55e' },
TRIALING: { label: t('Testphase'), color: '#38bdf8' },
PAST_DUE: { label: t('Zahlung ausstehend'), color: '#f59e0b' },
EXPIRED: { label: t('Abgelaufen'), color: '#6b7280' },
};
}
function _getPeriodMap(t: (k: string) => string): Record<string, string> {
return {
MONTHLY: t('Monatlich'),
YEARLY: t('Jährlich'),
NONE: '—',
};
}
const _storageOveragePerGbMonth = 0.5;
// ============================================================================
// Plan Card
// ============================================================================
interface PlanCardProps {
plan: SubscriptionPlan;
isCurrent: boolean;
onActivate: (planKey: string) => void;
activatingPlanKey: string | null;
}
const _PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
const { t } = useLanguage();
const period = _getPeriodMap(t);
const activating = activatingPlanKey === plan.planKey;
const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0;
const isYearly = plan.billingPeriod === 'YEARLY';
const monthlyEquivalent = isYearly ? plan.pricePerUserCHF / 12 : null;
return (
<div style={{
border: isCurrent ? '2px solid var(--primary-color, #F25843)' : '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
background: isCurrent ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.08))' : 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
minWidth: 240,
position: 'relative',
transition: 'box-shadow 0.2s',
}}>
{/* Header */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
<strong style={{ fontSize: '1.1rem' }}>{plan.title}</strong>
{isCurrent && (
<span style={{
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
}}>{t('Aktuell')}</span>
)}
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0, lineHeight: 1.4 }}>
{plan.description}
</p>
</div>
{/* Pricing */}
{!isFreePlan && (
<div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, lineHeight: 1.2 }}>
{_formatCurrency(plan.pricePerUserCHF)}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
{t('pro User')} / {period[plan.billingPeriod] || plan.billingPeriod}
</div>
{monthlyEquivalent != null && (
<div style={{ fontSize: '0.75rem', color: 'var(--primary-color, #F25843)', fontWeight: 500, marginTop: '0.15rem' }}>
{_formatCurrency(monthlyEquivalent)} {t('pro User / Monat')}
</div>
)}
</div>
)}
{/* Features list */}
{!isFreePlan && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '0.85rem' }}>
<_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules ?? 0 })} />
{(plan.pricePerFeatureInstanceCHF ?? 0) > 0 && (
<_FeatureRow icon="" text={t('Zusatzmodul: {price} / Monat', { price: _formatCurrency(plan.pricePerFeatureInstanceCHF) })} />
)}
<_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User / Monat', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} />
<_FeatureRow
icon="💾"
text={
plan.maxDataVolumeMB == null
? t('Speicher: unbegrenzt')
: t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) })
}
/>
{plan.maxUsers != null && (
<_FeatureRow icon="👥" text={t('Max. {count} User', { count: plan.maxUsers })} />
)}
</div>
)}
{/* Trial info */}
{isFreePlan && plan.trialDays && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', fontSize: '0.85rem' }}>
<_FeatureRow icon="🎁" text={t('{days} Tage kostenlos', { days: plan.trialDays })} />
{plan.maxUsers && <_FeatureRow icon="👤" text={t('{count} Users maximal', { count: plan.maxUsers })} />}
{(plan.includedModules ?? 0) > 0 && <_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules })} />}
{(plan.budgetAiPerUserCHF ?? 0) > 0 && <_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} />}
{plan.maxDataVolumeMB != null && <_FeatureRow icon="💾" text={t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) })} />}
</div>
)}
{/* Action */}
{!isCurrent && (
<button
onClick={() => onActivate(plan.planKey)}
disabled={!!activatingPlanKey}
style={{
marginTop: 'auto', padding: '10px 16px', borderRadius: '8px', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
cursor: activatingPlanKey ? 'wait' : 'pointer',
opacity: activatingPlanKey ? 0.6 : 1, fontSize: '0.9rem',
transition: 'opacity 0.2s',
}}
>
{activating
? t('Weiterleitung…')
: (!isFreePlan && !plan.trialDays) ? t('Abonnieren') : t('Auswählen')}
</button>
)}
</div>
);
};
const _FeatureRow: React.FC<{ icon: string; text: string }> = ({ icon, text }) => (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span style={{ fontSize: '0.8rem', flexShrink: 0, width: '1.2rem', textAlign: 'center' }}>{icon}</span>
<span>{text}</span>
</div>
);
// ============================================================================
// Usage Metric
// ============================================================================
interface UsageMetricProps {
label: string;
value: number;
max?: number;
formatValue?: (v: number) => string;
}
const _UsageMetric: React.FC<UsageMetricProps> = ({ label, value, max, formatValue }) => {
const fmt = formatValue ?? ((v: number) => String(v));
const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null;
const isWarning = percent != null && percent >= 80;
const barColor = isWarning ? '#f59e0b' : 'var(--primary-color, #F25843)';
return (
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>
{label}
</div>
<div style={{ fontSize: '0.95rem', fontWeight: 600 }}>
{fmt(value)}
{max != null && (
<span style={{ fontWeight: 400, color: 'var(--text-secondary)' }}> / {fmt(max)}</span>
)}
</div>
{percent != null && (
<div style={{
marginTop: '0.3rem', height: 4, borderRadius: 2,
background: 'var(--color-border, rgba(255,255,255,0.1))',
overflow: 'hidden',
}}>
<div style={{
width: `${percent}%`, height: '100%', borderRadius: 2,
background: barColor, transition: 'width 0.3s ease',
}} />
</div>
)}
</div>
);
};
// ============================================================================
// Subscription Info Card
// ============================================================================
interface SubInfoProps {
sub: MandateSubscription;
plan: SubscriptionPlan | null;
usage: SubscriptionUsage | null;
label: string;
onCancel?: (id: string) => void;
onReactivate?: (id: string) => void;
cancelling: boolean;
reactivating: boolean;
justPaid?: boolean;
}
const _SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, usage, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => {
const { t } = useLanguage();
const statusMap = _getStatusMap(t);
const periodMap = _getPeriodMap(t);
const statusInfo = statusMap[sub.status] || statusMap.EXPIRED;
const isActive = sub.status === 'ACTIVE';
const isPending = sub.status === 'PENDING';
const isScheduled = sub.status === 'SCHEDULED';
return (
<div style={{
border: '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
background: 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
}}>
{/* Header */}
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{label}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '1.15rem' }}>{plan ? plan.title : sub.planKey}</strong>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{isActive && !sub.recurring && (
<span style={{
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
background: '#ef4444', color: '#fff', fontWeight: 600,
}}>{t('Gekündigt')}</span>
)}
<span style={{
fontSize: '0.7rem', padding: '2px 10px', borderRadius: '12px',
background: statusInfo.color, color: '#fff', fontWeight: 600,
}}>{statusInfo.label}</span>
</div>
</div>
{/* Pending notice */}
{isPending && (
<div style={{
padding: '0.6rem 0.8rem', borderRadius: '8px',
background: justPaid ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)',
border: `1px solid ${justPaid ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
color: justPaid ? '#22c55e' : '#f59e0b', fontSize: '0.85rem',
}}>
{justPaid
? t('Zahlung erfolgreich Abonnement wird aktiviert')
: t('Zahlung noch nicht eingegangen')}
</div>
)}
{/* Scheduled notice */}
{isScheduled && sub.effectiveFrom && (
<div style={{
padding: '0.6rem 0.8rem', borderRadius: '8px',
background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)',
color: '#8b5cf6', fontSize: '0.85rem',
}}>
{t('Wird am {date} aktiv, wenn das aktuelle Abonnement ausläuft.', { date: _formatDate(sub.effectiveFrom) })}
</div>
)}
{/* Details grid */}
{!isPending && !isScheduled && (
<div style={{
fontSize: '0.85rem', color: 'var(--text-secondary)',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
}}>
<span>{t('Gestartet:')} {_formatDate(sub.startedAt)}</span>
{plan && <span>{t('Periode:')} {periodMap[plan.billingPeriod] || '—'}</span>}
{sub.currentPeriodEnd && <span>{t('Periodenende:')} {_formatDate(sub.currentPeriodEnd)}</span>}
{sub.trialEndsAt && <span>{t('Trial endet:')} {_formatDate(sub.trialEndsAt)}</span>}
{isActive && !sub.recurring && sub.currentPeriodEnd && (
<span style={{ color: '#ef4444' }}>{t('Läuft aus am:')} {_formatDate(sub.currentPeriodEnd)}</span>
)}
</div>
)}
{/* Plan details */}
{plan && !isPending && !isScheduled && (
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem 1.5rem',
fontSize: '0.85rem', color: 'var(--text-secondary)',
paddingTop: '0.5rem', borderTop: '1px solid var(--color-border, rgba(255,255,255,0.06))',
}}>
<span>{t('AI-Budget:')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User / Monat')}</span>
<span>{t('Module inkl.:')} {plan.includedModules ?? 0}</span>
<span>
{t('Speicher:')}{' '}
{plan.maxDataVolumeMB == null
? t('unbegrenzt')
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span>
<span>{t('Speicher über Plan:')} {_formatCurrency(_storageOveragePerGbMonth)} {t('/ GB / Monat')}</span>
</div>
)}
{/* Current usage */}
{usage && !isPending && !isScheduled && (
<div style={{
marginTop: '0.25rem', padding: '0.75rem', borderRadius: '8px',
background: 'var(--bg-tertiary, rgba(255,255,255,0.04))',
border: '1px solid var(--color-border, var(--border-color, #333))',
}}>
<div style={{
fontSize: '0.7rem', color: 'var(--text-secondary)',
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.5rem',
}}>
{t('Aktuelle Nutzung')}
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '0.5rem',
}}>
<_UsageMetric label={t('User')} value={usage.activeUsers} max={plan?.maxUsers ?? undefined} />
<_UsageMetric label={t('Module')} value={usage.activeInstances} max={plan?.includedModules ?? undefined} />
<_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} />
</div>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.25rem' }}>
{isActive && !sub.recurring && onReactivate && (
<button onClick={() => onReactivate(sub.id)} disabled={reactivating} style={{
padding: '8px 16px', borderRadius: '8px', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff',
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
}}>
{reactivating ? t('Wird reaktiviert…') : t('Reaktivieren')}
</button>
)}
{isActive && sub.recurring && onCancel && (
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
padding: '8px 16px', borderRadius: '8px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}>
{cancelling ? t('Wird gekündigt…') : t('Kündigen')}
</button>
)}
{(isPending || isScheduled) && onCancel && (
<button onClick={() => onCancel(sub.id)} disabled={cancelling} style={{
padding: '8px 16px', borderRadius: '8px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500, cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}>
{cancelling ? t('Wird abgebrochen…') : t('Abbrechen')}
</button>
)}
</div>
</div>
);
};
// ============================================================================
// Subscription Tab
// ============================================================================
interface SubscriptionTabProps {
mandateId: string;
}
export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) => {
const { t } = useLanguage();
const {
plans,
subscription,
plan: currentPlan,
scheduled,
usage,
loading,
error,
activatePlan,
cancelSubscription,
reactivateSubscription,
verifyCheckout,
} = useSubscription(mandateId);
const { confirm, ConfirmDialog } = useConfirm();
const [activatingPlanKey, setActivatingPlanKey] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [reactivating, setReactivating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'info'; text: string } | null>(null);
const [justPaid, setJustPaid] = useState(false);
const verifyCalledRef = useRef(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('success') === 'true') {
const sessionId = params.get('session_id') || '';
setCheckoutMessage({ type: 'success', text: t('Zahlung erfolgreich — Abonnement wird aktiviert…') });
setJustPaid(true);
const url = new URL(window.location.href);
url.searchParams.delete('success');
url.searchParams.delete('session_id');
window.history.replaceState({}, '', url.toString());
if (sessionId && !verifyCalledRef.current) {
verifyCalledRef.current = true;
const _pollUntilActive = async (retries = 5, delayMs = 2000) => {
try {
const result = await verifyCheckout(sessionId);
if (result.status === 'activated') {
setCheckoutMessage({ type: 'success', text: t('Abonnement wurde aktiviert.') });
setJustPaid(false);
return;
}
} catch { /* retry */ }
if (retries > 0) {
await new Promise(r => setTimeout(r, delayMs));
await _pollUntilActive(retries - 1, delayMs);
}
};
_pollUntilActive();
}
} else if (params.get('canceled') === 'true') {
setCheckoutMessage({ type: 'info', text: t('Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.') });
const url = new URL(window.location.href);
url.searchParams.delete('canceled');
window.history.replaceState({}, '', url.toString());
}
}, [verifyCheckout, t]);
useEffect(() => {
if (!justPaid) return;
if (subscription && subscription.status !== 'PENDING') {
setJustPaid(false);
setCheckoutMessage({ type: 'success', text: t('Abonnement wurde aktiviert.') });
}
}, [justPaid, subscription, t]);
const _handleActivate = useCallback(async (planKey: string) => {
setActivatingPlanKey(planKey);
setActionError(null);
try {
await activatePlan(planKey);
} catch (err: any) {
setActionError(err?.response?.data?.detail || err.message || t('Fehler beim Aktivieren'));
} finally {
setActivatingPlanKey(null);
}
}, [activatePlan, t]);
const _handleCancel = useCallback(async (subscriptionId: string) => {
const sub = subscription?.id === subscriptionId ? subscription : scheduled;
const isPendingOrScheduled = sub?.status === 'PENDING' || sub?.status === 'SCHEDULED';
const ok = await confirm(
isPendingOrScheduled
? t('Diesen Vorgang abbrechen?')
: t('Abonnement kündigen? Es bleibt bis zum Periodenende aktiv.'),
{
title: isPendingOrScheduled ? t('Vorgang abbrechen') : t('Abonnement kündigen'),
confirmLabel: isPendingOrScheduled ? t('Ja, abbrechen') : t('Kündigen'),
cancelLabel: isPendingOrScheduled ? t('Nein, zurück') : t('Abbrechen'),
variant: 'danger',
},
);
if (!ok) return;
setCancelling(true);
setActionError(null);
try {
await cancelSubscription(subscriptionId);
setCheckoutMessage(null);
} catch (err: any) {
setActionError(err?.response?.data?.detail || err.message || t('Fehler'));
} finally {
setCancelling(false);
}
}, [cancelSubscription, subscription, scheduled, confirm, t]);
const _handleReactivate = useCallback(async (subscriptionId: string) => {
setReactivating(true);
setActionError(null);
try {
await reactivateSubscription(subscriptionId);
} catch (err: any) {
setActionError(err?.response?.data?.detail || err.message || t('Fehler beim Reaktivieren'));
} finally {
setReactivating(false);
}
}, [reactivateSubscription, t]);
if (loading && !subscription) {
return <div className={styles.loadingPlaceholder}>{t('Abonnementdaten werden geladen…')}</div>;
}
return (
<div>
{/* Checkout feedback */}
{checkoutMessage && (
<div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-color, #F25843)'}`,
color: checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-light, #F25843)',
fontSize: '0.9rem',
}}>
{checkoutMessage.text}
</div>
)}
{/* Error display */}
{(error || actionError) && (
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
{actionError || error}
</div>
)}
{/* Current subscription */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Aktuelles Abonnement')}</h2>
{subscription ? (
<_SubInfoCard
sub={subscription}
plan={currentPlan}
usage={usage}
label={subscription.status === 'PENDING'
? (justPaid ? t('Zahlung wird verarbeitet…') : t('Checkout läuft…'))
: t('Operatives Abonnement')}
onCancel={_handleCancel}
onReactivate={_handleReactivate}
cancelling={cancelling}
reactivating={reactivating}
justPaid={justPaid}
/>
) : (
<div className={styles.noData}>
{t('Kein aktives Abonnement. Wählen Sie unten einen Plan.')}
</div>
)}
</section>
{/* Scheduled successor */}
{scheduled && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Geplanter Nachfolgeplan')}</h2>
<_SubInfoCard
sub={scheduled}
plan={null}
usage={null}
label={t('Startet nach Ablauf des aktuellen Plans')}
onCancel={_handleCancel}
cancelling={cancelling}
reactivating={false}
/>
</section>
)}
{/* Available plans */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Verfügbare Pläne')}</h2>
{plans.length === 0 ? (
<div className={styles.noData}>{t('Keine Pläne verfügbar')}</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1.25rem',
}}>
{plans.map((p) => (
<_PlanCard
key={p.planKey}
plan={p}
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
onActivate={_handleActivate}
activatingPlanKey={activatingPlanKey}
/>
))}
</div>
)}
</section>
<ConfirmDialog />
</div>
);
};