359 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|