This commit is contained in:
Andras Schmelczer 2026-02-18 21:22:15 +00:00
parent 524580eb25
commit ffe080adef
82 changed files with 2652 additions and 2956 deletions

View file

@ -1,7 +1,6 @@
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';
@ -134,15 +133,6 @@ export default function AuthModal({
<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 */}

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' | 'invite' | 'support';
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'invite';
export default function Header({
activePage,
@ -127,21 +127,20 @@ export default function Header({
</button>
{user && (
<button
className={tabClass('saved-searches')}
onClick={() => onPageChange('saved-searches')}
className={tabClass('account')}
onClick={() => onPageChange('account')}
>
Saved
Account
</button>
)}
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
Learn
</button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
Support
</button>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
)}
</nav>
)}
</div>
@ -203,7 +202,7 @@ export default function Header({
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} onPageChange={onPageChange} />
<UserMenu user={user} onLogout={onLogout} />
) : (
<>
<button

View file

@ -1,14 +0,0 @@
import type { ReactNode } from 'react';
interface LabelProps {
children: ReactNode;
className?: string;
}
export function Label({ children, className }: LabelProps) {
return (
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>
{children}
</label>
);
}

View file

@ -5,7 +5,6 @@ interface LicenseSuccessModalProps {
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
// Generate confetti particles once
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@ -17,11 +16,11 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
Math.floor(Math.random() * 6)
],
size: 6 + Math.random() * 6,
isCircle: Math.random() > 0.5,
})),
[]
);
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
@ -29,7 +28,6 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
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
@ -41,7 +39,7 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
borderRadius: p.isCircle ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
@ -49,13 +47,12 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
))}
</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.
Your lifetime access is now active.
</p>
</div>
<div className="px-6 py-6">
@ -71,7 +68,6 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
</div>
</div>
{/* CSS animation for confetti */}
<style>{`
@keyframes confetti-fall {
0% {

View file

@ -80,10 +80,8 @@ export default function MobileMenu({
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{mobileNavItem('support', 'Support')}
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}

View file

@ -1,3 +1,5 @@
import { useRef, useCallback, useLayoutEffect, useState as useStateR } from 'react';
import { createPortal } from 'react-dom';
import type React from 'react';
import type { SearchResult } from '../../hooks/useLocationSearch';
import { SearchIcon } from './icons/SearchIcon';
@ -26,6 +28,33 @@ interface PlaceSearchInputProps {
inputClassName?: string;
inputRef?: React.Ref<HTMLInputElement>;
onInputChange?: () => void;
portal?: boolean;
}
function useDropdownPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
) {
const [pos, setPos] = useStateR<{ top: number; left: number; width: number } | null>(null);
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, [anchorRef]);
useLayoutEffect(() => {
if (!open) return;
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [open, update]);
return pos;
}
export function PlaceSearchInput({
@ -37,13 +66,76 @@ export function PlaceSearchInput({
inputClassName,
inputRef,
onInputChange,
portal,
}: PlaceSearchInputProps) {
const sm = size === 'sm';
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false);
const showDropdown = search.open && search.results.length > 0;
const dropdown = showDropdown && (
<div
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
style={
portal && dropdownPos
? { position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width, zIndex: 50 }
: undefined
}
>
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
} ${
idx === search.activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => search.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(result);
}}
>
{result.type === 'postcode' ? (
<>
<SearchIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : (
<>
<MapPinIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({result.city})
</span>
)}
</span>
</>
)}
</button>
))}
</div>
);
return (
<div className="relative flex-1 min-w-0">
<div ref={wrapperRef} className="relative flex-1 min-w-0">
<input
ref={inputRef}
type="text"
@ -66,57 +158,9 @@ export function PlaceSearchInput({
/>
)}
{search.open && search.results.length > 0 && (
<div
className={`absolute top-full left-0 right-0 mt-1 bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto z-20`}
>
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
} ${
idx === search.activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => search.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(result);
}}
>
{result.type === 'postcode' ? (
<>
<SearchIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : (
<>
<MapPinIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({result.city})
</span>
)}
</span>
</>
)}
</button>
))}
</div>
{showDropdown && (portal
? createPortal(dropdown, document.body)
: <div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
)}
</div>
);

View file

@ -8,7 +8,7 @@ interface UpgradeModalProps {
onLoginClick: () => void;
onRegisterClick: () => void;
onStartCheckout: () => Promise<void>;
onDismiss: () => void;
onZoomToFreeZone: () => void;
}
export default function UpgradeModal({
@ -16,7 +16,7 @@ export default function UpgradeModal({
onLoginClick,
onRegisterClick,
onStartCheckout,
onDismiss,
onZoomToFreeZone,
}: UpgradeModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -56,7 +56,7 @@ export default function UpgradeModal({
<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}
onClick={onZoomToFreeZone}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
@ -96,7 +96,7 @@ export default function UpgradeModal({
{loading
? 'Redirecting...'
: isFree
? 'Claim free license'
? 'Claim free access'
: `Upgrade for ${priceLabel}`}
</button>
) : (
@ -121,10 +121,10 @@ export default function UpgradeModal({
)}
<button
onClick={onDismiss}
onClick={onZoomToFreeZone}
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
Or zoom back to demo area
</button>
</div>
</div>

View file

@ -1,15 +1,12 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { Page } from './Header';
export default function UserMenu({
user,
onLogout,
onPageChange,
}: {
user: AuthUser;
onLogout: () => void;
onPageChange: (page: Page) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -46,15 +43,6 @@ export default function UserMenu({
</p>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onPageChange('account');
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</button>
<button
onClick={() => {
setOpen(false);

View file

@ -1,11 +0,0 @@
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>
);
}