This commit is contained in:
Andras Schmelczer 2026-03-15 17:38:26 +00:00
parent 80c093b7ba
commit f72c43a9fa
101 changed files with 2168 additions and 1177 deletions

View file

@ -16,7 +16,10 @@ export function CollapsibleGroupHeader({
children,
}: CollapsibleGroupHeaderProps) {
return (
<button onClick={onToggle} className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}>
<button
onClick={onToggle}
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}

View file

@ -1,10 +1,4 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
@ -42,9 +36,7 @@ export function DestinationDropdown({
if (!filter) return destinations;
const lower = filter.toLowerCase();
return destinations.filter(
(d) =>
d.name.toLowerCase().includes(lower) ||
d.city?.toLowerCase().includes(lower),
(d) => d.name.toLowerCase().includes(lower) || d.city?.toLowerCase().includes(lower)
);
}, [destinations, filter]);
@ -79,16 +71,14 @@ export function DestinationDropdown({
setFilter('');
setActiveIndex(-1);
},
[onSelect],
[onSelect]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) =>
prev < filtered.length - 1 ? prev + 1 : prev,
);
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
@ -102,7 +92,7 @@ export function DestinationDropdown({
setFilter('');
}
},
[filtered, activeIndex, handleSelect],
[filtered, activeIndex, handleSelect]
);
const handleOpen = useCallback(() => {
@ -170,10 +160,7 @@ export function DestinationDropdown({
<span className="text-warm-700 dark:text-warm-200 truncate">
{dest.name}
{dest.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({dest.city})
</span>
<span className="text-warm-400 dark:text-warm-500"> ({dest.city})</span>
)}
</span>
</button>
@ -185,7 +172,9 @@ export function DestinationDropdown({
return (
<div ref={containerRef} className="relative">
<div className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}>
<div
className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}
>
<button
type="button"
onClick={handleOpen}
@ -194,9 +183,13 @@ export function DestinationDropdown({
{loading ? (
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
) : (
<MapPinIcon className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`} />
<MapPinIcon
className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`}
/>
)}
<span className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}>
<span
className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}
>
{value || placeholder}
</span>
</button>

View file

@ -14,7 +14,15 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite';
export type Page =
| 'home'
| 'dashboard'
| 'learn'
| 'pricing'
| 'account'
| 'saved'
| 'invites'
| 'invite';
export const PAGE_PATHS: Record<Page, string> = {
home: '/',
@ -128,27 +136,51 @@ export default function Header({
{/* Desktop nav */}
{!isMobile && (
<nav className="flex items-center gap-2">
<a href={PAGE_PATHS.dashboard} className={tabClass('dashboard')} onClick={(e) => navLink('dashboard', e)}>
<a
href={PAGE_PATHS.dashboard}
className={tabClass('dashboard')}
onClick={(e) => navLink('dashboard', e)}
>
Dashboard
</a>
{user && (
<>
<a href={PAGE_PATHS.saved} className={tabClass('saved')} onClick={(e) => navLink('saved', e)}>
<a
href={PAGE_PATHS.saved}
className={tabClass('saved')}
onClick={(e) => navLink('saved', e)}
>
Saved
</a>
<a href={PAGE_PATHS.invites} className={tabClass('invites')} onClick={(e) => navLink('invites', e)}>
<a
href={PAGE_PATHS.invites}
className={tabClass('invites')}
onClick={(e) => navLink('invites', e)}
>
Invite
</a>
<a href={PAGE_PATHS.account} className={tabClass('account')} onClick={(e) => navLink('account', e)}>
<a
href={PAGE_PATHS.account}
className={tabClass('account')}
onClick={(e) => navLink('account', e)}
>
Account
</a>
</>
)}
<a href={PAGE_PATHS.learn} className={tabClass('learn')} onClick={(e) => navLink('learn', e)}>
<a
href={PAGE_PATHS.learn}
className={tabClass('learn')}
onClick={(e) => navLink('learn', e)}
>
Learn
</a>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<a href={PAGE_PATHS.pricing} className={tabClass('pricing')} onClick={(e) => navLink('pricing', e)}>
<a
href={PAGE_PATHS.pricing}
className={tabClass('pricing')}
onClick={(e) => navLink('pricing', e)}
>
Pricing
</a>
)}

View file

@ -51,9 +51,7 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
<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 access is now active.
</p>
<p className="text-warm-300 text-sm mt-2">Your lifetime access is now active.</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">

View file

@ -85,7 +85,9 @@ export default function MobileMenu({
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{mobileNavItem('learn', 'Learn')}
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('saved', 'Saved')}
{user && mobileNavItem('invites', 'Invite')}
{user && mobileNavItem('account', 'Account')}

View file

@ -14,10 +14,7 @@ interface SearchHook {
open: boolean;
setOpen: (open: boolean) => void;
handleInputChange: (value: string) => void;
handleKeyDown: (
e: React.KeyboardEvent,
onSelect: (result: SearchResult) => void,
) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
}
interface PlaceSearchInputProps {
@ -56,16 +53,20 @@ export function PlaceSearchInput({
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 }
? {
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}`
result.type === 'postcode' ? `pc-${result.label}` : `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
@ -83,23 +84,16 @@ export function PlaceSearchInput({
>
{result.type === 'postcode' ? (
<>
<SearchIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<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`}
/>
<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 className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
)}
</span>
</>
@ -133,10 +127,12 @@ export function PlaceSearchInput({
/>
)}
{showDropdown && (portal
? createPortal(dropdown, document.body)
: <div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</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

@ -22,7 +22,11 @@ function Digit({ char, delay, active }: { char: string; delay: number; active: b
}}
>
{DIGITS.split('').map((d) => (
<span key={d} className="block text-center" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
<span
key={d}
className="block text-center"
style={{ height: `${H}em`, lineHeight: `${H}em` }}
>
{d}
</span>
))}

View file

@ -32,11 +32,7 @@ export default function UpgradeModal({
}, []);
const priceLabel =
pricePence === null
? '...'
: pricePence === 0
? 'Free'
: `\u00A3${pricePence / 100}`;
pricePence === null ? '...' : pricePence === 0 ? 'Free' : `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
@ -76,9 +72,7 @@ export default function UpgradeModal({
<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>
)}
{!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

View file

@ -1,13 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
export default function UserMenu({
user,
onLogout,
}: {
user: AuthUser;
onLogout: () => void;
}) {
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);

View file

@ -11,11 +11,7 @@ export function LogoIcon({ className = 'w-4 h-4' }: IconProps) {
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 2L20.7 7v10L12 22l-8.7-5V7z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2L20.7 7v10L12 22l-8.7-5V7z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.5 12.5l2.5 2.5 4.5-5" />
</svg>
);

View file

@ -6,8 +6,14 @@ export function SparklesIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
<path
d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z"
opacity=".7"
/>
<path
d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z"
opacity=".4"
/>
</svg>
);
}