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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user