widerrufsbelehrung hinzugefügt und in den checkout mit eingebunden. refund ins backend eingebaut.
This commit is contained in:
22
resources/js/components/ui/scroll-area.tsx
Normal file
22
resources/js/components/ui/scroll-area.tsx
Normal 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';
|
||||
@@ -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)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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