perfect-postcode/frontend/src/components/invite/InvitePage.tsx

359 lines
13 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import HexCanvas from '../home/HexCanvas';
import type { AuthUser } from '../../hooks/useAuth';
interface InvitePageProps {
code: string;
user: AuthUser | null;
theme: 'light' | 'dark';
screenshotMode?: boolean;
onLoginClick: () => void;
onRegisterClick: () => void;
onLicenseGranted: () => void;
}
interface InviteInfo {
valid: boolean;
invite_type: string;
used: boolean;
invited_by: string | null;
}
const CONFETTI_COLORS = ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
function Confetti() {
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 2,
duration: 2 + Math.random() * 2,
color: CONFETTI_COLORS[Math.floor(Math.random() * 6)],
size: 6 + Math.random() * 6,
isCircle: Math.random() > 0.5,
})),
[]
);
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 2 }}>
{particles.map((p) => (
<div
key={p.id}
className="absolute animate-confetti"
style={{
left: `${p.left}%`,
top: '-10px',
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: p.isCircle ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
<style>{`
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti-fall linear forwards;
}
`}</style>
</div>
);
}
export default function InvitePage({
code,
user,
theme,
screenshotMode = false,
onLoginClick,
onRegisterClick,
onLicenseGranted,
}: InvitePageProps) {
const { t } = useTranslation();
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redeemed, setRedeemed] = useState(false);
const [pricePence, setPricePence] = useState<number | null>(null);
const isDark = theme === 'dark';
// Signal screenshot readiness once loading completes and a frame has painted
useEffect(() => {
if (screenshotMode && !loading) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.__screenshot_ready = true;
});
});
}
}, [screenshotMode, loading]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [inviteRes, pricingRes] = await Promise.all([
fetch(apiUrl(`invite/${encodeURIComponent(code)}`)),
fetch(apiUrl('pricing')),
]);
if (!inviteRes.ok) throw new Error('Failed to validate invite');
const data: InviteInfo = await inviteRes.json();
if (!cancelled) setInvite(data);
if (pricingRes.ok) {
const pricing = await pricingRes.json();
if (!cancelled) setPricePence(pricing.current_price_pence);
}
} catch {
if (!cancelled) setError(t('invitePage.failedToValidate'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [code, t]);
const handleRedeem = useCallback(async () => {
if (!user) return;
setRedeeming(true);
setError(null);
try {
const res = await fetch(apiUrl('redeem-invite'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
}),
});
assertOk(res, 'Redeem invite');
const data = await res.json();
if (data.result === 'licensed') {
setRedeemed(true);
onLicenseGranted();
} else if (data.checkout_url) {
window.location.href = data.checkout_url;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to redeem invite');
} finally {
setRedeeming(false);
}
}, [code, user, onLicenseGranted]);
if (screenshotMode && loading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900" />
);
}
if (screenshotMode) {
const isAdminInvite = invite?.valid && !invite.used && invite.invite_type === 'admin';
const isValid = invite?.valid && !invite.used;
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900">
<div className="w-[90%] bg-white dark:bg-warm-800 rounded-3xl border border-warm-200 dark:border-warm-700 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-16 py-10 text-center">
<h2 className="text-7xl leading-tight font-bold text-white mb-3">
{isValid
? isAdminInvite
? t('invitePage.youreInvited')
: t('invitePage.specialOffer')
: t('header.appName')}
</h2>
<p className="text-warm-300 text-3xl leading-snug">
{isValid && invite.invited_by
? isAdminInvite
? t('invitePage.invitedByFree', { name: invite.invited_by })
: t('invitePage.invitedByDiscount', { name: invite.invited_by })
: isValid
? isAdminInvite
? t('invitePage.genericFreeInvite')
: t('invitePage.genericDiscount')
: t('invitePage.exploreEvery')}
</p>
</div>
<div className="px-16 py-8 text-center">
{isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-5xl mr-4">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-[96px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">
{t('upgrade.once')}
</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-3xl">
{t('invitePage.propertyInfo')}
</p>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<SpinnerIcon className="w-8 h-8 text-teal-400 animate-spin relative z-10" />
</div>
);
}
if (error && !invite) {
return (
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<div className="text-center relative z-10">
<p className="text-lg font-medium text-white mb-2">{t('invitePage.invalidInvite')}</p>
<p className="text-warm-400">{error}</p>
</div>
</div>
);
}
if (!invite?.valid || invite.used) {
return (
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<div className="text-center max-w-sm mx-4 relative z-10">
<p className="text-lg font-medium text-white mb-2">
{invite?.used ? t('invitePage.inviteAlreadyUsed') : t('invitePage.invalidInviteLink')}
</p>
<p className="text-warm-400">
{invite?.used
? t('invitePage.inviteAlreadyUsedDesc')
: t('invitePage.invalidInviteLinkDesc')}
</p>
</div>
</div>
);
}
if (redeemed) {
return (
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<Confetti />
<div className="text-center max-w-sm mx-4 relative z-10">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-400" />
</div>
<p className="text-lg font-medium text-white mb-2">{t('invitePage.licenseActivated')}</p>
<p className="text-warm-400">{t('invitePage.fullAccessGranted')}</p>
</div>
</div>
);
}
const isAdminInvite = invite.invite_type === 'admin';
return (
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<Confetti />
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden relative z-10">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">
{isAdminInvite ? t('invitePage.youreInvited') : t('invitePage.specialOffer')}
</h2>
<p className="text-warm-300 text-sm">
{invite.invited_by
? isAdminInvite
? t('invitePage.invitedByFree', { name: invite.invited_by })
: t('invitePage.invitedByDiscount', { name: invite.invited_by })
: isAdminInvite
? t('invitePage.genericFreeInvite')
: t('invitePage.genericDiscount')}
</p>
</div>
<div className="px-6 py-6">
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="text-center mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-xl mr-2">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">{t('upgrade.once')}</span>
</div>
)}
{user?.subscription === 'licensed' ? (
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-6 h-6 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-warm-700 dark:text-warm-300 font-medium">
{t('invitePage.youAlreadyHaveLicense')}
</p>
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">
{t('invitePage.accountHasFullAccess')}
</p>
</div>
) : user ? (
<button
onClick={handleRedeem}
disabled={redeeming}
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg shadow-lg shadow-teal-600/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{redeeming && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{isAdminInvite
? redeeming
? t('invitePage.activating')
: t('invitePage.activateLicense')
: redeeming
? t('upgrade.redirecting')
: t('invitePage.claimDiscount')}
</button>
) : (
<div className="flex flex-col gap-3">
<button
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
{t('invitePage.registerToClaim')}
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('upgrade.alreadyHaveAccount')}
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
</div>
</div>
);
}