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

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
viewportClassName?: string;
}
export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, viewportClassName, ...props }, ref) => (
<div
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<div className={cn('h-full w-full overflow-auto', viewportClassName)}>
{children}
</div>
</div>
)
);
ScrollArea.displayName = 'ScrollArea';

View File

@@ -185,6 +185,8 @@ const [canUpload, setCanUpload] = useState(true);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const liveRegionRef = useRef<HTMLDivElement | null>(null);
const cameraViewportRef = useRef<HTMLDivElement | null>(null);
const cameraShellRef = useRef<HTMLElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const countdownTimerRef = useRef<number | null>(null);
@@ -481,6 +483,25 @@ const [canUpload, setCanUpload] = useState(true);
setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred }));
}, []);
const handleToggleImmersive = useCallback(async () => {
setImmersiveMode((prev) => !prev);
const shell = cameraShellRef.current;
if (!shell) return;
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
: false;
if (prefersReducedMotion) return;
try {
if (!document.fullscreenElement) {
await shell.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (error) {
console.warn('Fullscreen toggle failed', error);
}
}, []);
const triggerConfetti = useCallback(async () => {
if (typeof window === 'undefined') return;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
@@ -883,14 +904,14 @@ const [canUpload, setCanUpload] = useState(true);
) : null;
const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? (
<div className="absolute left-4 right-4 top-6 z-30 rounded-3xl border border-white/30 bg-black/60 p-4 text-white shadow-2xl backdrop-blur sm:left-6 sm:right-6 sm:top-8">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/70">Bereit für dein Foto?</p>
<p className="text-lg font-semibold leading-tight">Teile den Moment mit allen Gästen.</p>
<p className="text-sm text-white/80">Zieh dir eine Mission oder starte direkt mit der Kamera.</p>
<div className="absolute left-4 right-4 top-4 z-30 rounded-2xl border border-white/25 bg-black/60 px-4 py-3 text-white shadow-2xl backdrop-blur sm:left-6 sm:right-6 sm:top-5">
<div className="flex items-center justify-between gap-2">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Bereit für dein Foto?</p>
<p className="text-base font-semibold leading-tight">Teile den Moment mit allen Gästen.</p>
<p className="text-xs text-white/75">Zieh eine Mission oder starte direkt.</p>
</div>
<Badge variant="secondary" className="rounded-full bg-white/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white/90">
<Badge variant="secondary" className="rounded-full bg-white/15 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-white/90">
Live
</Badge>
</div>
@@ -898,7 +919,10 @@ const [canUpload, setCanUpload] = useState(true);
<Button
size="sm"
className="rounded-full bg-white text-black shadow"
onClick={() => navigate(tasksUrl)}
onClick={() => {
setShowHeroOverlay(false);
navigate(tasksUrl);
}}
>
Mission ziehen
</Button>
@@ -906,11 +930,14 @@ const [canUpload, setCanUpload] = useState(true);
size="sm"
variant="secondary"
className="rounded-full border border-white/30 bg-white/10 text-white"
onClick={() => navigate(tasksUrl)}
onClick={() => {
setShowHeroOverlay(false);
navigate(tasksUrl);
}}
>
Stimmung wählen
</Button>
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-white/85">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 text-[11px] text-white/85">
<Sparkles className="h-4 w-4 text-amber-200" />
Mini-Mission: Fang ein Lachen ein
</span>
@@ -1069,14 +1096,26 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
return renderWithDialog(
<>
<div className="relative flex min-h-screen flex-col gap-6 pt-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<div
ref={cameraShellRef as unknown as React.RefObject<HTMLDivElement>}
className="relative flex min-h-screen flex-col gap-4 pb-[calc(env(safe-area-inset-bottom,0px)+12px)] pt-3"
style={bodyFont ? { fontFamily: bodyFont } : undefined}
>
{taskFloatingCard}
{heroOverlay}
<section
className="relative flex min-h-[70vh] flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }}
>
<div className="relative aspect-[3/4] sm:aspect-video">
<div
ref={cameraViewportRef}
className="relative w-full"
style={{
height: 'calc(100vh - 160px)',
minHeight: '70vh',
maxHeight: '90vh',
}}
>
<video
ref={videoRef}
className={cn(
@@ -1123,7 +1162,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-30 flex justify-center">
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/20 bg-black/40 px-3 py-2 backdrop-blur">
<Button
size="icon"
@@ -1197,7 +1236,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
controlIconButtonBase,
immersiveMode && 'border-white bg-white text-black'
)}
onClick={() => setImmersiveMode((prev) => !prev)}
onClick={handleToggleImmersive}
title={
immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
@@ -1299,9 +1338,9 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
{isCountdownActive && (
<div
className="absolute inset-0 rounded-full"
className="absolute inset-1 rounded-full"
style={{
background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.15) ${countdownDegrees}deg)`,
background: `conic-gradient(#fff ${countdownDegrees}deg, rgba(255,255,255,0.12) ${countdownDegrees}deg)`,
}}
/>
)}

View File

@@ -14,6 +14,7 @@ const Footer: React.FC = () => {
impressum: localizedPath('/impressum'),
datenschutz: localizedPath('/datenschutz'),
agb: localizedPath('/agb'),
widerruf: localizedPath('/widerrufsbelehrung'),
kontakt: localizedPath('/kontakt'),
}), [localizedPath]);
@@ -57,6 +58,11 @@ const Footer: React.FC = () => {
{t('legal:agb')}
</Link>
</li>
<li>
<Link href={links.widerruf} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
{t('legal:widerrufsbelehrung')}
</Link>
</li>
<li>
<Link href={links.kontakt} className="transition-colors hover:text-pink-500 dark:hover:text-pink-300">
{t('marketing:nav.contact')}

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;
}