Hintergründe zum EventInvitePage Layout Customizer hinzugefügt. Badge und CTA entfernt, Textfelder zu Textareas gemacht. Geschenkgutscheine verbessert, E-Mail-Versand ergänzt + Resend + Confirmationseite mit Code-Copy und Link zur Package-Seite, die den Code als URL-Parameter enthält.

This commit is contained in:
Codex Agent
2025-12-08 16:20:04 +01:00
parent 046e2fe3ec
commit 4784c23e70
35 changed files with 1503 additions and 136 deletions

View File

@@ -7,14 +7,22 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { fetchGiftVoucherTiers, createGiftVoucherCheckout, type GiftVoucherTier } from '@/lib/giftVouchers';
import {
fetchGiftVoucherTiers,
createGiftVoucherCheckout,
fetchGiftVoucherByCode,
type GiftVoucherTier,
type GiftVoucherLookupResponse,
} from '@/lib/giftVouchers';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { cn } from '@/lib/utils';
import { useRateLimitHelper } from '@/hooks/useRateLimitHelper';
function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) {
const [tiers, setTiers] = React.useState<GiftVoucherTier[]>(initial);
const [loading, setLoading] = React.useState(initial.length === 0);
const [error, setError] = React.useState<string | null>(null);
const { locale } = useLocalizedRoutes();
React.useEffect(() => {
if (initial.length > 0) {
@@ -22,10 +30,14 @@ function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) {
return;
}
fetchGiftVoucherTiers()
.then(setTiers)
.then((data) => {
const preferredCurrency = locale === 'en' ? 'USD' : 'EUR';
const preferred = data.filter((tier) => tier.currency === preferredCurrency && tier.can_checkout);
setTiers(preferred.length > 0 ? preferred : data);
})
.catch((err) => setError(err?.message || 'Failed to load tiers'))
.finally(() => setLoading(false));
}, [initial]);
}, [initial, locale]);
return { tiers, loading, error };
}
@@ -45,6 +57,11 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
accept_terms: false,
});
const [errors, setErrors] = React.useState<Record<string, string | null>>({});
const [lookupCode, setLookupCode] = React.useState('');
const [lookupResult, setLookupResult] = React.useState<GiftVoucherLookupResponse | null>(null);
const [lookupError, setLookupError] = React.useState<string | null>(null);
const [lookupLoading, setLookupLoading] = React.useState(false);
const rateLimit = useRateLimitHelper('voucher');
const selectedTierKey = form.tier_key;
@@ -96,6 +113,10 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
return_url: returnUrl,
});
if (response.id) {
sessionStorage.setItem('gift_checkout_id', response.id);
}
if (response.checkout_url) {
window.location.assign(response.checkout_url);
} else {
@@ -108,6 +129,32 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
}
};
const onLookup = async () => {
if (rateLimit.isLimited(lookupCode)) {
setLookupError(t('gift.too_many_attempts'));
return;
}
setLookupLoading(true);
setLookupError(null);
setLookupResult(null);
try {
const result = await fetchGiftVoucherByCode(lookupCode);
if (result) {
setLookupResult(result);
rateLimit.clear(lookupCode);
} else {
setLookupError(t('gift.lookup_not_found'));
rateLimit.bump(lookupCode);
}
} catch (error: any) {
setLookupError(error?.message || t('gift.lookup_not_found'));
rateLimit.bump(lookupCode);
} finally {
setLookupLoading(false);
}
};
return (
<MarketingLayout title={t('gift.title')}>
<section className="relative overflow-hidden bg-gradient-to-b from-background via-muted/40 to-background">
@@ -147,7 +194,6 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
selectedTierKey === tier.key ? 'border-primary shadow-lg' : '',
!tier.can_checkout && 'opacity-60'
)}
onClick={() => tier.can_checkout && setValue('tier_key', tier.key, { shouldValidate: true })}
onClick={() => tier.can_checkout && updateField('tier_key', tier.key)}
>
<CardHeader>
@@ -247,6 +293,53 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
</Button>
</CardFooter>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>{t('gift.lookup_title')}</CardTitle>
<CardDescription>{t('gift.lookup_subtitle')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-[2fr,1fr,auto]">
<div className="space-y-1 md:col-span-2">
<Label htmlFor="lookup_code">{t('gift.lookup_label')}</Label>
<Input
id="lookup_code"
placeholder="GIFT-XXXXXX"
value={lookupCode}
onChange={(e) => setLookupCode(e.target.value)}
/>
</div>
<div className="flex items-end">
<Button onClick={onLookup} disabled={lookupLoading || !lookupCode.trim()}>
{lookupLoading ? t('gift.processing') : t('gift.lookup_cta')}
</Button>
</div>
{lookupError && <p className="md:col-span-3 text-sm text-destructive">{lookupError}</p>}
{lookupResult && (
<div className="md:col-span-3 space-y-1 rounded-lg border bg-muted/40 p-4">
<p className="text-sm font-semibold">
{t('gift.lookup_result_code', { code: lookupResult.code })}
</p>
<p className="text-sm text-muted-foreground">
{t('gift.lookup_result_value', {
amount: lookupResult.amount.toFixed(2),
currency: lookupResult.currency,
})}
</p>
{lookupResult.expires_at && (
<p className="text-sm text-muted-foreground">
{t('gift.lookup_result_expires', {
date: new Date(lookupResult.expires_at).toLocaleDateString(locale || undefined),
})}
</p>
)}
<p className="text-sm text-muted-foreground">
{t(`gift.lookup_status.${lookupResult.status}`, lookupResult.status)}
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</section>

View File

@@ -7,6 +7,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useLocale } from '@/hooks/useLocale';
import { ADMIN_HOME_PATH } from '@/admin/constants';
import { Button } from '@/components/ui/button';
import { fetchGiftVoucherByCheckout, type GiftVoucherLookupResponse } from '@/lib/giftVouchers';
type SuccessProps = {
type?: string;
@@ -16,6 +17,58 @@ const GiftSuccess: React.FC = () => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const locale = useLocale();
const [voucher, setVoucher] = React.useState<GiftVoucherLookupResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const checkoutId = params.get('checkout_id') || sessionStorage.getItem('gift_checkout_id');
const transactionId = params.get('transaction_id');
fetchGiftVoucherByCheckout(checkoutId || undefined, transactionId || undefined)
.then((data) => {
if (data) {
setVoucher(data);
} else {
setError(t('success.gift_lookup_failed'));
}
})
.catch(() => setError(t('success.gift_lookup_failed')))
.finally(() => setLoading(false));
}, [t]);
const onCopy = async () => {
if (!voucher?.code) return;
try {
await navigator.clipboard.writeText(voucher.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
setError(t('success.gift_copy_failed'));
}
};
const onShare = async () => {
if (!voucher?.code) return;
const text = t('success.gift_share_text', {
code: voucher.code,
amount: voucher.amount.toFixed(2),
currency: voucher.currency,
});
if (navigator.share) {
try {
await navigator.share({ title: t('success.gift_title'), text });
return;
} catch (e) {
// fall back to copy
}
}
await onCopy();
};
return (
<MarketingLayout title={t('success.gift_title')}>
@@ -24,6 +77,42 @@ const GiftSuccess: React.FC = () => {
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
<h1 className="text-3xl font-bold text-foreground">{t('success.gift_title')}</h1>
<p className="text-muted-foreground">{t('success.gift_description')}</p>
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
<h2 className="text-lg font-semibold">{t('success.gift_code_title')}</h2>
{loading && <p className="text-muted-foreground">{t('success.gift_loading')}</p>}
{error && <p className="text-destructive">{error}</p>}
{voucher && (
<div className="mt-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-muted/50 px-4 py-3">
<div className="text-left">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('success.gift_code_label')}</p>
<p className="font-mono text-lg font-bold">{voucher.code}</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onCopy}>
{copied ? t('success.gift_copied') : t('success.gift_copy')}
</Button>
<Button size="sm" onClick={onShare}>
{t('success.gift_share')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">
{t('success.gift_value', {
amount: voucher.amount.toFixed(2),
currency: voucher.currency,
})}
</p>
{voucher.expires_at && (
<p className="text-sm text-muted-foreground">
{t('success.gift_expires', {
date: new Date(voucher.expires_at).toLocaleDateString(locale || undefined),
})}
</p>
)}
</div>
)}
</div>
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
<h2 className="text-lg font-semibold">{t('success.gift_bullets_title')}</h2>
<ul className="mt-3 list-disc space-y-2 pl-5 text-muted-foreground">

View File

@@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator';
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
import { cn } from '@/lib/utils';
import { useRateLimitHelper } from '@/hooks/useRateLimitHelper';
import toast from 'react-hot-toast';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
@@ -154,6 +155,9 @@ export const PaymentStep: React.FC = () => {
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
const [withdrawalLoading, setWithdrawalLoading] = useState(false);
const [withdrawalError, setWithdrawalError] = useState<string | null>(null);
const RateLimitHelper = useRateLimitHelper('coupon');
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(null);
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
const paddleLocale = useMemo(() => {
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
@@ -177,6 +181,11 @@ export const PaymentStep: React.FC = () => {
return;
}
if (RateLimitHelper.isLimited(trimmed)) {
setCouponError(t('coupon.errors.too_many_attempts'));
return;
}
setCouponLoading(true);
setCouponError(null);
setCouponNotice(null);
@@ -190,6 +199,8 @@ export const PaymentStep: React.FC = () => {
amount: preview.pricing.formatted.discount,
})
);
setVoucherExpiry(preview.coupon.expires_at ?? null);
setIsGiftVoucher(preview.coupon.code?.toUpperCase().startsWith('GIFT-') ?? false);
if (typeof window !== 'undefined') {
localStorage.setItem('preferred_coupon_code', preview.coupon.code);
}
@@ -197,6 +208,7 @@ export const PaymentStep: React.FC = () => {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic'));
RateLimitHelper.bump(trimmed);
} finally {
setCouponLoading(false);
}
@@ -742,9 +754,26 @@ export const PaymentStep: React.FC = () => {
<span>{t('coupon.fields.total')}</span>
<span>{couponPreview.pricing.formatted.total}</span>
</div>
{voucherExpiry && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t('coupon.fields.expires')}</span>
<span>{new Date(voucherExpiry).toLocaleDateString(i18n.language)}</span>
</div>
)}
</div>
</div>
)}
{isGiftVoucher && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground">
<span>{t('coupon.legal_note')}{' '}</span>
<a
href={i18n.language === 'de' ? '/de/widerrufsbelehrung' : '/en/withdrawal'}
className="text-primary underline"
>
{t('coupon.legal_link')}
</a>
</div>
)}
</div>
{!inlineActive && (