widerrufsbelehrung hinzugefügt und in den checkout mit eingebunden. refund ins backend eingebaut.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user