This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -1,5 +1,7 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { AppleIcon } from './icons/AppleIcon';
type View = 'login' | 'register' | 'forgot';
@ -7,6 +9,7 @@ export default function AuthModal({
onClose,
onLogin,
onRegister,
onOAuthLogin,
onForgotPassword,
loading,
error,
@ -16,6 +19,7 @@ export default function AuthModal({
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string) => Promise<void>;
onOAuthLogin: (provider: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
@ -57,6 +61,18 @@ export default function AuthModal({
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
);
const handleOAuth = useCallback(
async (provider: string) => {
try {
await onOAuthLogin(provider);
onClose();
} catch {
// Error is handled by the hook
}
},
[onOAuthLogin, onClose]
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
@ -104,82 +120,117 @@ export default function AuthModal({
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
<div className="p-5 space-y-4">
{/* OAuth buttons (hidden in forgot view) */}
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<>
<div className="space-y-2">
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
onClick={() => handleOAuth('google')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
>
Forgot password?
<GoogleIcon className="w-4 h-4" />
Continue with Google
</button>
)}
<button
type="button"
onClick={() => handleOAuth('apple')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded bg-navy-950 dark:bg-white text-white dark:text-navy-950 text-sm font-medium hover:bg-navy-900 dark:hover:bg-warm-100 disabled:opacity-50 disabled:cursor-wait"
>
<AppleIcon className="w-4 h-4" />
Continue with Apple
</button>
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
<span className="text-xs text-warm-400 dark:text-warm-500">or</span>
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
</div>
</>
)}
{/* Email form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
)}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
</button>
)}
</div>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
</div>
</div>
</div>
);

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account' | 'invite' | 'support';
export default function Header({
activePage,
@ -139,6 +139,9 @@ export default function Header({
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
Support
</button>
</nav>
)}
</div>

View file

@ -0,0 +1,92 @@
import { useEffect, useMemo } from 'react';
interface LicenseSuccessModalProps {
onClose: () => void;
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
// Generate confetti particles once
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 2,
duration: 2 + Math.random() * 2,
color: ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'][
Math.floor(Math.random() * 6)
],
size: 6 + Math.random() * 6,
})),
[]
);
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
{/* Confetti */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{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: Math.random() > 0.5 ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
</div>
{/* Card */}
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="text-5xl mb-3">🎉</div>
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
<p className="text-warm-300 text-sm mt-2">
Your lifetime license is now active.
</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
You now have full access to every feature across all of England. Happy exploring!
</p>
<button
onClick={onClose}
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
Start exploring
</button>
</div>
</div>
{/* CSS animation for confetti */}
<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>
);
}

View file

@ -83,6 +83,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{mobileNavItem('support', 'Support')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}

View file

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl } from '../../lib/api';
interface UpgradeModalProps {
isLoggedIn: boolean;
onLoginClick: () => void;
onRegisterClick: () => void;
onStartCheckout: () => Promise<void>;
onDismiss: () => void;
}
export default function UpgradeModal({
isLoggedIn,
onLoginClick,
onRegisterClick,
onStartCheckout,
onDismiss,
}: UpgradeModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pricePence, setPricePence] = useState<number | null>(null);
useEffect(() => {
fetch(apiUrl('pricing'))
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) setPricePence(data.current_price_pence);
})
.catch(() => {});
}, []);
const priceLabel =
pricePence === null
? '...'
: pricePence === 0
? 'Free'
: `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
setLoading(true);
setError(null);
try {
await onStartCheckout();
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
{/* Close button */}
<button
onClick={onDismiss}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
</button>
{/* Header */}
<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">Unlock the full map</h2>
<p className="text-warm-300 text-sm">
Free users can explore inner London. Upgrade for lifetime access to all of England.
</p>
</div>
{/* Body */}
<div className="px-6 py-6">
<div className="flex items-baseline justify-center gap-1 mb-4">
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
{priceLabel}
</span>
{!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>
)}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
{isFree
? 'Free for early adopters. No credit card required.'
: 'One-time payment. Lifetime access. 30-day money-back guarantee.'}
</p>
{isLoggedIn ? (
<button
onClick={handleUpgrade}
disabled={loading}
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 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{loading
? 'Redirecting...'
: isFree
? 'Claim free license'
: `Upgrade for ${priceLabel}`}
</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"
>
Register & Upgrade
</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"
>
Already have an account? Log in
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
onClick={onDismiss}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
>
Or zoom back into London
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function VerificationBanner({
email,
onRequestVerification,
onDismiss,
}: {
email: string;
onRequestVerification: (email: string) => Promise<void>;
onDismiss: () => void;
}) {
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const handleResend = useCallback(async () => {
setSending(true);
try {
await onRequestVerification(email);
setSent(true);
setTimeout(() => setSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setSending(false);
}
}, [email, onRequestVerification]);
return (
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-2.5 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Please verify your email address. Check your inbox.
</p>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleResend}
disabled={sending || sent}
className="text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 disabled:opacity-50 flex items-center gap-1"
>
{sending && <SpinnerIcon className="w-3.5 h-3.5 animate-spin" />}
{sent ? 'Sent!' : 'Resend'}
</button>
<button
onClick={onDismiss}
className="text-amber-400 dark:text-amber-600 hover:text-amber-600 dark:hover:text-amber-400 text-lg leading-none"
aria-label="Dismiss"
>
&times;
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function AppleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}

View file

@ -0,0 +1,26 @@
interface IconProps {
className?: string;
}
export function GoogleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}