widerrufsbelehrung hinzugefügt und in den checkout mit eingebunden. refund ins backend eingebaut.

This commit is contained in:
Codex Agent
2025-12-07 11:57:05 +01:00
parent e092f72475
commit 1d3d49e05a
44 changed files with 1143 additions and 71 deletions

View File

@@ -10,6 +10,10 @@ import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
@@ -120,6 +124,9 @@ export const PaymentStep: React.FC = () => {
const [message, setMessage] = useState<string>('');
const [initialised, setInitialised] = useState(false);
const [inlineActive, setInlineActive] = useState(false);
const [acceptedTerms, setAcceptedTerms] = useState(false);
const [acceptedWaiver, setAcceptedWaiver] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const [couponCode, setCouponCode] = useState<string>(() => {
if (typeof window === 'undefined') {
return '';
@@ -142,6 +149,11 @@ export const PaymentStep: React.FC = () => {
const eventCallbackRef = useRef<(event: Record<string, unknown>) => void>();
const hasAutoAppliedCoupon = useRef(false);
const checkoutContainerClass = 'paddle-checkout-container';
const [showWithdrawalModal, setShowWithdrawalModal] = useState(false);
const [withdrawalHtml, setWithdrawalHtml] = useState<string | null>(null);
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
const [withdrawalLoading, setWithdrawalLoading] = useState(false);
const [withdrawalError, setWithdrawalError] = useState<string | null>(null);
const paddleLocale = useMemo(() => {
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
@@ -149,6 +161,7 @@ export const PaymentStep: React.FC = () => {
}, [i18n.language]);
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const requiresImmediateWaiver = useMemo(() => Boolean(selectedPackage?.activates_immediately), [selectedPackage]);
const applyCoupon = useCallback(async (code: string) => {
if (!selectedPackage) {
@@ -228,6 +241,11 @@ export const PaymentStep: React.FC = () => {
}, [couponCode]);
const handleFreeActivation = async () => {
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
setConsentError(t('checkout.legal.checkbox_terms_error'));
return;
}
setPaymentCompleted(true);
nextStep();
};
@@ -237,6 +255,11 @@ export const PaymentStep: React.FC = () => {
return;
}
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
setConsentError(t('checkout.legal.checkbox_terms_error'));
return;
}
if (!selectedPackage.paddle_price_id) {
setStatus('error');
setMessage(t('checkout.payment_step.paddle_not_configured'));
@@ -282,13 +305,15 @@ export const PaymentStep: React.FC = () => {
frameInitialHeight: '550',
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
theme: 'light',
locale: paddleLocale,
},
customData: {
package_id: String(selectedPackage.id),
locale: paddleLocale,
},
};
locale: paddleLocale,
},
customData: {
package_id: String(selectedPackage.id),
locale: paddleLocale,
accepted_terms: acceptedTerms ? '1' : '0',
accepted_waiver: requiresImmediateWaiver && acceptedWaiver ? '1' : '0',
},
};
const customerEmail = authUser?.email ?? null;
if (customerEmail) {
@@ -329,6 +354,8 @@ export const PaymentStep: React.FC = () => {
package_id: selectedPackage.id,
locale: paddleLocale,
coupon_code: couponPreview?.coupon.code ?? undefined,
accepted_terms: acceptedTerms,
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
}),
});
@@ -489,6 +516,31 @@ export const PaymentStep: React.FC = () => {
}
}, []);
const openWithdrawalModal = useCallback(async () => {
setShowWithdrawalModal(true);
if (withdrawalHtml || withdrawalLoading) {
return;
}
setWithdrawalLoading(true);
setWithdrawalError(null);
try {
const response = await fetch(`/api/v1/legal/widerrufsbelehrung?lang=${paddleLocale}`);
if (!response.ok) {
throw new Error(`Failed to load withdrawal page (${response.status})`);
}
const data = await response.json();
setWithdrawalHtml(data.body_html || '');
setWithdrawalTitle(data.title || t('checkout.legal.link_cancellation'));
} catch (error) {
setWithdrawalError(t('checkout.legal.modal_error'));
} finally {
setWithdrawalLoading(false);
}
}, [paddleLocale, t, withdrawalHtml, withdrawalLoading]);
if (!selectedPackage) {
return (
<Alert variant="destructive">
@@ -554,15 +606,85 @@ export const PaymentStep: React.FC = () => {
</div>
</div>
<div className="flex flex-col items-stretch gap-3 w-full max-w-sm">
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/>
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
<div className="space-y-3 rounded-xl border border-white/30 bg-white/10 p-4">
<div className="space-y-2">
<div className="flex items-start gap-3">
<Checkbox
id="checkout-terms-hero"
checked={acceptedTerms}
onCheckedChange={(checked) => {
setAcceptedTerms(Boolean(checked));
if (consentError) {
setConsentError(null);
}
}}
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-terms-hero" className="cursor-pointer text-white">
{t('checkout.legal.checkbox_terms_label')}
</Label>
<p className="text-xs text-white/80">
{t('checkout.legal.legal_links_intro')}{' '}
<button
type="button"
className="underline underline-offset-2"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openWithdrawalModal();
}}
>
{t('checkout.legal.open_withdrawal')}
</button>
</p>
</div>
</div>
{requiresImmediateWaiver && (
<div className="flex items-start gap-3">
<Checkbox
id="checkout-waiver-hero"
checked={acceptedWaiver}
onCheckedChange={(checked) => {
setAcceptedWaiver(Boolean(checked));
if (consentError) {
setConsentError(null);
}
}}
className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]"
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-waiver-hero" className="cursor-pointer text-white">
{t('checkout.legal.checkbox_digital_content_label')}
</Label>
<p className="text-xs text-white/80">
{t('checkout.legal.hint_subscription_withdrawal')}
</p>
</div>
</div>
)}
{consentError && (
<div className="flex items-center gap-2 text-sm text-red-200">
<XCircle className="h-4 w-4" />
<span>{consentError}</span>
</div>
)}
</div>
<div className="space-y-2">
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/>
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
</div>
</div>
</div>
</div>
</div>
@@ -632,7 +754,7 @@ export const PaymentStep: React.FC = () => {
</p>
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
disabled={status === 'processing' || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
isProcessing={status === 'processing'}
className={PRIMARY_CTA_STYLES}
/>
@@ -664,6 +786,37 @@ export const PaymentStep: React.FC = () => {
</p>
</div>
</div>
<Dialog open={showWithdrawalModal} onOpenChange={setShowWithdrawalModal}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{withdrawalTitle || t('checkout.legal.link_cancellation')}</DialogTitle>
<DialogDescription>{t('checkout.legal.modal_description')}</DialogDescription>
</DialogHeader>
<div className="min-h-[200px]">
{withdrawalLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" />
<span>{t('checkout.legal.modal_loading')}</span>
</div>
)}
{withdrawalError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4" />
<span>{withdrawalError}</span>
</div>
)}
{!withdrawalLoading && !withdrawalError && withdrawalHtml && (
<ScrollArea className="max-h-[60vh] rounded-md border" viewportClassName="p-3">
<div
className="prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: withdrawalHtml }}
/>
</ScrollArea>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -26,6 +26,7 @@ export interface CheckoutPackage {
limits?: Record<string, unknown>;
paddle_price_id?: string | null;
paddle_product_id?: string | null;
activates_immediately?: boolean;
[key: string]: unknown;
}