vibes
This commit is contained in:
parent
80c093b7ba
commit
f72c43a9fa
101 changed files with 2168 additions and 1177 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue