All changes

This commit is contained in:
Andras Schmelczer 2026-03-14 21:36:00 +00:00
parent 593f380581
commit 49f7ec2f5a
60 changed files with 1783 additions and 679 deletions

View file

@ -1,12 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
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;
@ -16,11 +19,68 @@ 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,
@ -32,6 +92,15 @@ export default function InvitePage({
const [redeemed, setRedeemed] = useState(false);
const [pricePence, setPricePence] = useState<number | null>(null);
const isDark = theme === 'dark';
// Signal screenshot readiness once loading completes
useEffect(() => {
if (screenshotMode && !loading) {
window.__screenshot_ready = true;
}
}, [screenshotMode, loading]);
useEffect(() => {
let cancelled = false;
(async () => {
@ -86,20 +155,75 @@ export default function InvitePage({
}
}, [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-[85%] bg-white dark:bg-warm-800 rounded-2xl 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-12 py-14 text-center">
<h2 className="text-4xl font-bold text-white mb-4">
{isValid
? isAdminInvite
? "You\u2019re invited!"
: 'Special offer!'
: 'Perfect Postcode'}
</h2>
<p className="text-warm-300 text-lg">
{isValid && invite.invited_by
? isAdminInvite
? `${invite.invited_by} has invited you to get free lifetime access.`
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
: isValid
? isAdminInvite
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'
: 'Explore every neighbourhood in England'}
</p>
</div>
<div className="px-12 py-10 text-center">
{isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="mb-6">
<span className="text-warm-400 dark:text-warm-500 line-through text-2xl mr-3">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-5xl 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-xl">/once</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-lg">
Property prices, energy ratings, crime stats, school ratings &amp; more
</p>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
<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-warm-50 dark:bg-navy-950">
<div className="text-center">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">Invalid invite</p>
<p className="text-warm-500 dark:text-warm-400">{error}</p>
<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">Invalid invite</p>
<p className="text-warm-400">{error}</p>
</div>
</div>
);
@ -107,12 +231,13 @@ export default function InvitePage({
if (!invite?.valid || invite.used) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
<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 ? 'Invite already used' : 'Invalid invite link'}
</p>
<p className="text-warm-500 dark:text-warm-400">
<p className="text-warm-400">
{invite?.used
? 'This invite link has already been redeemed.'
: 'This invite link is invalid or has expired.'}
@ -124,15 +249,17 @@ export default function InvitePage({
if (redeemed) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
<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-navy-950 dark:text-warm-100 mb-2">
<p className="text-lg font-medium text-white mb-2">
License activated!
</p>
<p className="text-warm-500 dark:text-warm-400">
<p className="text-warm-400">
You now have full access to Perfect Postcode.
</p>
</div>
@ -143,25 +270,25 @@ export default function InvitePage({
const isAdminInvite = invite.invite_type === 'admin';
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<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">
<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 ? "You're invited!" : 'Special offer!'}
</h2>
<p className="text-warm-300 text-sm">
{isAdminInvite
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'}
{invite.invited_by
? isAdminInvite
? `${invite.invited_by} has invited you to get free lifetime access.`
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
: isAdminInvite
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'}
</p>
</div>
<div className="px-6 py-6">
{isAdminInvite && (
<div className="text-center mb-4">
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime access</span>
</div>
)}
{!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">
@ -174,7 +301,15 @@ export default function InvitePage({
</div>
)}
{user ? (
{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">You already have a license</p>
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">Your account already has full access.</p>
</div>
) : user ? (
<button
onClick={handleRedeem}
disabled={redeeming}