gift voucher language fixes + move into user menu

This commit is contained in:
Codex Agent
2025-12-08 20:18:01 +01:00
parent bc960dc22b
commit 4bdb93c171
9 changed files with 199 additions and 89 deletions

View File

@@ -28,7 +28,8 @@ i18n
escapeValue: false,
},
backend: {
loadPath: '/lang/{{lng}}/{{ns}}.json',
// Cache-bust to ensure fresh translations when files change.
loadPath: '/lang/{{lng}}/{{ns}}.json?v=20250204',
},
react: {
useSuspense: false,

View File

@@ -287,6 +287,16 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">
{user.name ?? user.email}
</DropdownMenuLabel>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
router.visit(localizedPath('/voucher-status'));
}}
className="flex items-center gap-2 font-sans-marketing dark:text-gray-100"
>
<LayoutDashboard className="h-4 w-4" />
<span>{t('gift.lookup_title', 'Gutscheinstatus')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();

View File

@@ -10,34 +10,30 @@ 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) {
setTiers(initial.filter((tier) => tier.currency === 'EUR'));
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);
const eurOnly = data.filter((tier) => tier.currency === 'EUR');
setTiers(eurOnly.length > 0 ? eurOnly : data);
})
.catch((err) => setError(err?.message || 'Failed to load tiers'))
.finally(() => setLoading(false));
}, [initial, locale]);
}, [initial]);
return { tiers, loading, error };
}
@@ -57,11 +53,6 @@ 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;
@@ -129,31 +120,6 @@ 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')}>
@@ -294,52 +260,6 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
</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

@@ -0,0 +1,103 @@
import React from 'react';
import MarketingLayout from '@/layouts/mainWebsite';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { fetchGiftVoucherByCode, type GiftVoucherLookupResponse } from '@/lib/giftVouchers';
import { useRateLimitHelper } from '@/hooks/useRateLimitHelper';
const GiftVoucherStatus: React.FC = () => {
const { t, i18n } = useTranslation('marketing');
const [code, setCode] = React.useState('');
const [result, setResult] = React.useState<GiftVoucherLookupResponse | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const rateLimit = useRateLimitHelper('voucher');
const onLookup = async () => {
if (rateLimit.isLimited(code)) {
setError(t('gift.too_many_attempts'));
return;
}
setLoading(true);
setError(null);
setResult(null);
try {
const data = await fetchGiftVoucherByCode(code);
if (data) {
setResult(data);
rateLimit.clear(code);
} else {
setError(t('gift.lookup_not_found'));
rateLimit.bump(code);
}
} catch (e: any) {
setError(e?.message || t('gift.lookup_not_found'));
rateLimit.bump(code);
} finally {
setLoading(false);
}
};
return (
<MarketingLayout title={t('gift.lookup_title')}>
<section className="bg-gradient-to-b from-background via-muted/30 to-background py-16">
<div className="mx-auto flex max-w-3xl flex-col gap-8 px-4 sm:px-6">
<div className="space-y-3 text-center">
<h1 className="text-3xl font-bold text-foreground">{t('gift.lookup_title')}</h1>
<p className="text-muted-foreground">{t('gift.lookup_subtitle')}</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t('gift.lookup_label')}</CardTitle>
<CardDescription>{t('gift.lookup_subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="code">{t('gift.lookup_label')}</Label>
<Input
id="code"
placeholder="GIFT-XXXXXX"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<Button onClick={onLookup} disabled={loading || !code.trim()}>
{loading ? t('gift.processing', 'Checking...') : t('gift.lookup_cta')}
</Button>
{error && <p className="text-sm text-destructive">{error}</p>}
{result && (
<div className="space-y-2 rounded-md border bg-muted/30 p-4 text-sm">
<p className="font-semibold">{t('gift.lookup_result_code', { code: result.code })}</p>
<p className="text-muted-foreground">
{t('gift.lookup_result_value', {
amount: result.amount.toFixed(2),
currency: result.currency,
})}
</p>
{result.expires_at && (
<p className="text-muted-foreground">
{t('gift.lookup_result_expires', {
date: new Date(result.expires_at).toLocaleDateString(i18n.language),
})}
</p>
)}
<p className="text-muted-foreground">
{t(`gift.lookup_status.${result.status}`, result.status)}
</p>
</div>
)}
</CardContent>
</Card>
</div>
</section>
</MarketingLayout>
);
};
GiftVoucherStatus.layout = (page: React.ReactNode) => page;
export default GiftVoucherStatus;