623 lines
24 KiB
TypeScript
623 lines
24 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
};
|