Files
fotospiel-app/resources/js/pages/marketing/GiftVoucher.tsx

353 lines
15 KiB
TypeScript

import React from 'react';
import MarketingLayout from '@/layouts/mainWebsite';
import { useTranslation } from 'react-i18next';
import { Gift } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
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) {
setLoading(false);
return;
}
fetchGiftVoucherTiers()
.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, locale]);
return { tiers, loading, error };
}
function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[] }) {
const { t } = useTranslation('marketing');
const { locale } = useLocalizedRoutes();
const { tiers, loading, error } = useGiftVoucherTiers(initialTiers);
const [submitting, setSubmitting] = React.useState(false);
const [serverError, setServerError] = React.useState<string | null>(null);
const [form, setForm] = React.useState({
tier_key: initialTiers.find((t) => t.can_checkout)?.key ?? '',
purchaser_email: '',
recipient_email: '',
recipient_name: '',
message: '',
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;
const updateField = (key: keyof typeof form, value: string | boolean) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const validate = (): boolean => {
const nextErrors: Record<string, string | null> = {};
if (!form.tier_key) {
nextErrors.tier_key = t('gift.error_select_tier');
}
if (!form.purchaser_email || !form.purchaser_email.includes('@')) {
nextErrors.purchaser_email = t('gift.error_purchaser_email', 'Please enter a valid email.');
}
if (form.recipient_email && !form.recipient_email.includes('@')) {
nextErrors.recipient_email = t('gift.error_recipient_email', 'Please enter a valid email.');
}
if (!form.accept_terms) {
nextErrors.accept_terms = t('gift.accept_terms_required');
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const onSubmit = async () => {
if (!validate()) {
return;
}
setSubmitting(true);
setServerError(null);
try {
const successUrl = window.location.origin + `/${locale}/success?type=gift`;
const returnUrl = window.location.origin + `/${locale}/gift-card`;
const response = await createGiftVoucherCheckout({
tier_key: form.tier_key,
purchaser_email: form.purchaser_email,
recipient_email: form.recipient_email || undefined,
recipient_name: form.recipient_name || undefined,
message: form.message || undefined,
success_url: successUrl,
return_url: returnUrl,
});
if (response.id) {
sessionStorage.setItem('gift_checkout_id', response.id);
}
if (response.checkout_url) {
window.location.assign(response.checkout_url);
} else {
setServerError(t('gift.error_checkout'));
}
} catch (err: any) {
setServerError(err?.message || t('gift.error_checkout'));
} finally {
setSubmitting(false);
}
};
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">
<div className="absolute inset-0 opacity-10 blur-3xl bg-[radial-gradient(circle_at_20%_20%,#60a5fa,transparent_30%),radial-gradient(circle_at_80%_10%,#a855f7,transparent_25%),radial-gradient(circle_at_50%_80%,#22c55e,transparent_25%)]" />
<div className="relative mx-auto flex max-w-5xl flex-col gap-10 px-4 py-16 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-primary">
<Gift className="h-4 w-4" />
{t('gift.badge')}
</div>
<h1 className="text-4xl font-bold leading-tight text-foreground sm:text-5xl font-display">
{t('gift.headline')}
</h1>
<p className="max-w-3xl text-lg text-muted-foreground">{t('gift.subline')}</p>
<p className="text-sm text-muted-foreground">{t('gift.validity')}</p>
</div>
</div>
{error && <div className="rounded-md border border-destructive/50 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
{loading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="h-32 animate-pulse rounded-xl bg-muted" />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{tiers.map((tier) => (
<Card
key={tier.key}
className={cn(
'cursor-pointer transition hover:shadow-md',
selectedTierKey === tier.key ? 'border-primary shadow-lg' : '',
!tier.can_checkout && 'opacity-60'
)}
onClick={() => tier.can_checkout && updateField('tier_key', tier.key)}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{tier.label}</span>
<span className="text-xl font-semibold">
{tier.amount.toLocaleString()} {tier.currency}
</span>
</CardTitle>
<CardDescription>{t('gift.card_subline')}</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
<p>{t('gift.card_body')}</p>
{!tier.can_checkout && <p className="text-destructive">{t('gift.not_available')}</p>}
</CardContent>
</Card>
))}
</div>
)}
{errors.tier_key && <p className="mt-2 text-sm text-destructive">{errors.tier_key}</p>}
<div className="mt-6 rounded-xl border border-primary/20 bg-primary/5 px-5 py-4 text-sm text-foreground shadow-sm">
<p className="font-semibold">{t('gift.withdrawal.title')}</p>
<p className="text-muted-foreground">{t('gift.withdrawal.body')}</p>
<Button asChild variant="link" className="px-0 text-primary">
<a href={locale === 'en' ? '/en/withdrawal' : '/de/widerrufsbelehrung'}>
{t('gift.withdrawal.link', 'Widerrufsbelehrung öffnen')}
</a>
</Button>
</div>
</div>
<Card className="shadow-lg">
<CardHeader>
<CardTitle>{t('gift.form_title')}</CardTitle>
<CardDescription>{t('gift.form_subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label htmlFor="purchaser_email">{t('gift.purchaser_email')}</Label>
<Input
id="purchaser_email"
type="email"
placeholder="you@example.com"
value={form.purchaser_email}
onChange={(e) => updateField('purchaser_email', e.target.value)}
/>
{errors.purchaser_email && <p className="text-sm text-destructive">{errors.purchaser_email}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="recipient_name">{t('gift.recipient_name')}</Label>
<Input
id="recipient_name"
placeholder={t('gift.recipient_name_placeholder')}
value={form.recipient_name}
onChange={(e) => updateField('recipient_name', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="recipient_email">{t('gift.recipient_email')}</Label>
<Input
id="recipient_email"
type="email"
placeholder="friend@example.com"
value={form.recipient_email}
onChange={(e) => updateField('recipient_email', e.target.value)}
/>
{errors.recipient_email && <p className="text-sm text-destructive">{errors.recipient_email}</p>}
</div>
<div className="space-y-1">
<Label htmlFor="message">{t('gift.message')}</Label>
<Textarea
id="message"
rows={3}
placeholder={t('gift.message_placeholder')}
value={form.message}
onChange={(e) => updateField('message', e.target.value)}
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="accept_terms"
className="mt-1 h-4 w-4 rounded border-muted-foreground/50"
checked={form.accept_terms}
onChange={(e) => updateField('accept_terms', e.target.checked)}
/>
<Label htmlFor="accept_terms" className="text-sm leading-tight text-muted-foreground">
{t('gift.accept_terms')}
</Label>
</div>
{errors.accept_terms && <p className="text-sm text-destructive">{errors.accept_terms}</p>}
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" disabled={submitting || loading} onClick={onSubmit}>
{submitting ? t('gift.processing') : t('gift.cta')}
</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>
</MarketingLayout>
);
}
export default GiftVoucherPage;
GiftVoucherPage.layout = (page: React.ReactNode) => page;