All changes
This commit is contained in:
parent
593f380581
commit
49f7ec2f5a
60 changed files with 1783 additions and 679 deletions
|
|
@ -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 & 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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue