changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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% {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue