From 8032011708118ff1d7391c3dd12c0fcf55e82f5c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 22 Feb 2026 22:36:40 +0000 Subject: [PATCH] Good stuff --- Makefile.data | 35 +- frontend/src/App.tsx | 13 +- .../src/components/account/AccountPage.tsx | 4 +- frontend/src/components/map/AreaPane.tsx | 2 +- .../src/components/map/FeatureBrowser.tsx | 17 +- frontend/src/components/map/Filters.tsx | 65 +- frontend/src/components/map/Map.tsx | 1 + frontend/src/components/map/MapPage.tsx | 22 +- .../src/components/map/TravelTimeCard.tsx | 8 +- .../src/components/pricing/PricingPage.tsx | 49 +- frontend/src/components/ui/FeatureLabel.tsx | 5 + .../src/components/ui/SaveSearchModal.tsx | 103 ++- frontend/src/components/ui/UserMenu.tsx | 24 +- .../src/components/ui/icons/ChartBarIcon.tsx | 21 + .../components/ui/icons/GraduationCapIcon.tsx | 20 + .../src/components/ui/icons/HouseIcon.tsx | 20 + .../src/components/ui/icons/ShieldIcon.tsx | 19 + .../components/ui/icons/ShoppingBagIcon.tsx | 21 + frontend/src/components/ui/icons/TagIcon.tsx | 20 + frontend/src/components/ui/icons/TreeIcon.tsx | 20 + .../src/components/ui/icons/UsersIcon.tsx | 22 + frontend/src/components/ui/icons/index.ts | 8 + frontend/src/hooks/useDeckLayers.ts | 7 +- frontend/src/lib/group-icons.ts | 31 + frontend/src/types.ts | 3 + pipeline/download/places.py | 34 +- pipeline/download/transit_network.py | 598 ++++++++++++++---- pipeline/transform/merge.py | 46 -- server-rs/src/data/travel_time.rs | 53 +- server-rs/src/features.rs | 116 +++- server-rs/src/routes/features.rs | 10 + server-rs/src/routes/invites.rs | 9 +- 32 files changed, 1052 insertions(+), 374 deletions(-) create mode 100644 frontend/src/components/ui/icons/ChartBarIcon.tsx create mode 100644 frontend/src/components/ui/icons/GraduationCapIcon.tsx create mode 100644 frontend/src/components/ui/icons/HouseIcon.tsx create mode 100644 frontend/src/components/ui/icons/ShieldIcon.tsx create mode 100644 frontend/src/components/ui/icons/ShoppingBagIcon.tsx create mode 100644 frontend/src/components/ui/icons/TagIcon.tsx create mode 100644 frontend/src/components/ui/icons/TreeIcon.tsx create mode 100644 frontend/src/components/ui/icons/UsersIcon.tsx create mode 100644 frontend/src/lib/group-icons.ts diff --git a/Makefile.data b/Makefile.data index 278c8bd..5eb8eb5 100644 --- a/Makefile.data +++ b/Makefile.data @@ -28,8 +28,6 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/certificates.csv -JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet -JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet CRIME_DIR := $(MANUAL_DATA)/crime CRIME := $(DATA_DIR)/crime_by_lsoa.parquet @@ -68,8 +66,7 @@ PMTILES_VERSION := 1.22.3 download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-pbf download-places \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-school-proximity transform-geosure transform-postcode-boundaries \ - generate-postcode-boundaries \ - journey-times + generate-postcode-boundaries prepare: $(PRICES_STAMP) merge: $(MERGE_STAMP) @@ -185,32 +182,6 @@ $(GREENSPACE): $(PBF) $(PLACES): $(PBF) uv run python -m pipeline.download.places --output $@ --pbf $(PBF) -# ── Journey times (requires TFL_API_KEY) ────────────────────────────────────── - -$(JT_BANK): - @echo "" - @echo "=== TFL journey times (bank) not found ===" - @echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/" - @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" - @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank" - @echo "" - @exit 1 - -$(JT_FITZROVIA): - @echo "" - @echo "=== TFL journey times (fitzrovia) not found ===" - @echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/" - @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" - @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia" - @echo "" - @exit 1 - -journey-times: $(ARCGIS) -ifndef DEST - $(error DEST required — e.g. make journey-times DEST=bank) -endif - uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS) - # ── Transforms ──────────────────────────────────────────────────────────────── $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) @@ -256,15 +227,13 @@ $(PC_BOUNDARIES): # ── Final merge → postcode.parquet + properties.parquet ────────────────────── -$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ +$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL) uv run python -m pipeline.transform.merge \ --epc-pp $(EPC_PP) \ --arcgis $(ARCGIS) \ --iod $(IOD) \ --poi-proximity $(POI_PROXIMITY) \ - --journey-times-bank $(JT_BANK) \ - --journey-times-fitzrovia $(JT_FITZROVIA) \ --ethnicity $(ETHNICITY) \ --crime $(CRIME) \ --noise $(NOISE) \ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b45089..8e208e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,7 +77,10 @@ export default function App() { const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [initialLoading, setInitialLoading] = useState(true); const [pendingInfoFeature, setPendingInfoFeature] = useState(null); - const [inviteCode, setInviteCode] = useState(null); + const [inviteCode, setInviteCode] = useState(() => { + const fromPath = pathToPage(window.location.pathname); + return fromPath?.inviteCode ?? null; + }); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; @@ -91,13 +94,6 @@ export default function App() { return 'home'; }); - useEffect(() => { - const fromPath = pathToPage(window.location.pathname); - if (fromPath?.inviteCode) { - setInviteCode(fromPath.inviteCode); - } - }, []); - const { theme, toggleTheme } = useTheme(); const isMobile = useIsMobile(); const { @@ -366,6 +362,7 @@ export default function App() { setShowSaveModal(false)} onSave={savedSearches.saveSearch} + onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }} saving={savedSearches.saving} error={savedSearches.error} /> diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index d8f6c6b..a255c05 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -368,7 +368,7 @@ function SettingsContent({ {isLicensed && (

- {user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'} + {user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}

{inviteUrl ? (
@@ -397,7 +397,7 @@ function SettingsContent({ className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2" > {creatingInvite && } - {user.isAdmin ? 'Generate invite link' : 'Generate referral link'} + {user.isAdmin ? 'Generate free invite link' : 'Generate referral link'} )} {inviteError && ( diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 56bec78..704385c 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -133,6 +133,7 @@ export default function AreaPane({ ) : stats ? (
+ {hexagonLocation && } {featureGroups.map((group) => { const hasData = group.features.some( @@ -375,7 +376,6 @@ export default function AreaPane({
); })} - {hexagonLocation && }
) : null}
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 3f29d59..289b24c 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -9,10 +9,10 @@ import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; -import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons'; import type { ComponentType } from 'react'; import { IconButton } from '../ui/IconButton'; -import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; const MODE_ICONS: Record> = { car: CarIcon, @@ -96,9 +96,6 @@ export default function FeatureBrowser({ {(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => { - const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug); - const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null; - const isPinned = fieldKey !== null && pinnedFeature === fieldKey; const ModeIcon = MODE_ICONS[mode]; return (
- {fieldKey && ( - onTogglePin(fieldKey)} - active={isPinned} - title={isPinned ? 'Unpin color view' : 'Color map by this feature'} - size="md" - > - - - )} onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md"> diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 04cbe09..e3ad939 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -25,15 +25,6 @@ import { type ListingType = 'historical' | 'buy' | 'rent'; -const MODE_RESTRICTED_FEATURES: Record> = { - 'Bathrooms': new Set(['buy', 'rent']), -}; - -function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean { - const allowed = MODE_RESTRICTED_FEATURES[featureName]; - return !allowed || allowed.has(mode); -} - function SliderLabels({ min, max, @@ -89,7 +80,6 @@ interface FiltersProps { openInfoFeature?: string | null; onClearOpenInfoFeature?: () => void; travelTimeEntries: TravelTimeEntry[]; - travelTimeDataRanges: Map; onTravelTimeAddEntry: (mode: TransportMode) => void; onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; @@ -122,7 +112,6 @@ export default memo(function Filters({ openInfoFeature, onClearOpenInfoFeature, travelTimeEntries, - travelTimeDataRanges, onTravelTimeAddEntry, onTravelTimeRemoveEntry, onTravelTimeSetDestination, @@ -136,6 +125,36 @@ export default memo(function Filters({ onUpgradeClick, onResetTutorial, }: FiltersProps) { + const modeRestrictions = useMemo(() => { + const map: Record> = {}; + for (const f of features) { + if (f.modes && f.modes.length > 0) { + map[f.name] = new Set(f.modes as ListingType[]); + } + } + return map; + }, [features]); + + const linkedFeatures = useMemo(() => { + const pairs: [string, string][] = []; + const seen = new Set(); + for (const f of features) { + if (f.linked && !seen.has(f.name)) { + pairs.push([f.name, f.linked]); + seen.add(f.linked); + } + } + return pairs; + }, [features]); + + const isAllowed = useCallback( + (name: string, mode: ListingType) => { + const allowed = modeRestrictions[name]; + return !allowed || allowed.has(mode); + }, + [modeRestrictions] + ); + const activeListingType = useMemo((): ListingType => { const val = filters['Listing status'] as string[] | undefined; if (!val || val.length === 0) return 'historical'; @@ -145,8 +164,8 @@ export default memo(function Filters({ }, [filters]); const availableFeatures = useMemo( - () => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)), - [features, enabledFeatures, activeListingType] + () => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)), + [features, enabledFeatures, activeListingType, isAllowed] ); const enabledFeatureList = useMemo( () => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'), @@ -156,7 +175,22 @@ export default memo(function Filters({ const handleListingSelect = useCallback( (type: ListingType) => { for (const name of Object.keys(filters)) { - if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { + if (name === 'Listing status') continue; + if (isAllowed(name, type)) continue; + + // Check if this feature has a linked counterpart in the new mode + let swapped = false; + for (const [a, b] of linkedFeatures) { + const counterpart = name === a ? b : name === b ? a : null; + if (counterpart && isAllowed(counterpart, type)) { + onFilterChange(counterpart, filters[name] as [number, number]); + onRemoveFilter(name); + swapped = true; + break; + } + } + + if (!swapped) { onRemoveFilter(name); } } @@ -167,7 +201,7 @@ export default memo(function Filters({ }; onFilterChange('Listing status', [valueMap[type]]); }, - [filters, onFilterChange, onRemoveFilter] + [filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures] ); const containerRef = useRef(null); @@ -275,7 +309,6 @@ export default memo(function Filters({ label={entry.label} timeRange={entry.timeRange} useBest={entry.useBest} - dataRange={travelTimeDataRanges.get(index) ?? null} isPinned={pinnedFeature === travelFieldKey(entry)} onTogglePin={() => onTogglePin(travelFieldKey(entry))} onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index f43f87b..34790ea 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -170,6 +170,7 @@ export default memo(function Map({ data, postcodeData, usePostcodeView, + zoom: viewState.zoom, pois, viewFeature, colorRange, diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 4c92ec7..15bffa2 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -188,24 +188,6 @@ export default function MapPage({ const pois = usePOIData(mapData.bounds, selectedPOICategories); - const travelTimeDataRanges = useMemo((): globalThis.Map => { - const ranges = new globalThis.Map(); - for (let i = 0; i < travelTime.entries.length; i++) { - const entry = travelTime.entries[i]; - if (!entry.slug) continue; - const fieldName = `avg_${travelFieldKey(entry)}`; - const vals: number[] = []; - for (const item of mapData.data) { - const val = item[fieldName]; - if (typeof val === 'number' && !isNaN(val)) vals.push(val); - } - if (vals.length === 0) continue; - vals.sort((a, b) => a - b); - ranges.set(i, [vals[0], vals[vals.length - 1]]); - } - return ranges; - }, [travelTime.entries, mapData.data]); - useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); useEffect(() => { @@ -401,7 +383,6 @@ export default function MapPage({ openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} travelTimeEntries={travelTime.entries} - travelTimeDataRanges={travelTimeDataRanges} onTravelTimeAddEntry={travelTime.handleAddEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} @@ -625,9 +606,10 @@ export default function MapPage({ {/* Floating POI panel */} {poiPaneOpen && ( diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 3a68a2e..81df15a 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -27,7 +27,6 @@ interface TravelTimeCardProps { label: string; timeRange: [number, number] | null; useBest: boolean; - dataRange: [number, number] | null; isPinned: boolean; onTogglePin: () => void; onSetDestination: (slug: string, label: string) => void; @@ -42,7 +41,6 @@ export function TravelTimeCard({ label, timeRange, useBest, - dataRange, isPinned, onTogglePin, onSetDestination, @@ -74,8 +72,8 @@ export function TravelTimeCard({ [onSetDestination, search.clear], ); - const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0; - const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120; + const sliderMin = 0; + const sliderMax = 120; const displayRange = timeRange ?? [sliderMin, sliderMax]; const ModeIcon = MODE_ICONS[mode]; @@ -142,7 +140,7 @@ export function TravelTimeCard({ )} {/* Time range slider — only show when we have data */} - {slug && dataRange && ( + {slug && (
Max time diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 0e6fcc9..7c7a8ce 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -58,16 +58,6 @@ export default function PricingPage({ if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0); }, []); - useEffect(() => { - if (!pricing || !scrollRef.current || !activeCardRef.current) return; - if (currentTierIndex === 0) return; - const container = scrollRef.current; - const card = activeCardRef.current; - const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; - container.scrollLeft = Math.max(0, scrollLeft); - setScrolledLeft(container.scrollLeft > 0); - }, [pricing, currentTierIndex]); - useEffect(() => { fetch(apiUrl('pricing')) .then((res) => { @@ -98,6 +88,16 @@ export default function PricingPage({ } } + useEffect(() => { + if (!pricing || !scrollRef.current || !activeCardRef.current) return; + if (currentTierIndex === 0) return; + const container = scrollRef.current; + const card = activeCardRef.current; + const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; + container.scrollLeft = Math.max(0, scrollLeft); + setScrolledLeft(container.scrollLeft > 0); + }, [pricing, currentTierIndex]); + const ctaButton = isLicensed ? (
-
+

Early access pricing

- No subscriptions, no recurring fees. Pay once and get lifetime - access to every feature. The earlier you join, the less you pay. + Pay once, access forever. The earlier you join, the less you pay. +

+
+ +
+

+ Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, + £500 for a survey. Get the wrong area and you're stuck with a long + commute, bad schools, or a road you didn't know about. +

+

+ Less than your survey costs. Vastly more useful.

@@ -203,7 +213,7 @@ export default function PricingPage({
{scrolledLeft &&
}
-
+
{pricing.tiers.map((tier, i) => { const isCurrent = i === currentTierIndex; const isFilled = @@ -348,17 +358,6 @@ export default function PricingPage({ )}
-
-

- Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500. - Survey: £500. Moving costs: £1,000. And that's just the money. Get the - wrong area and you're stuck — with a long commute, bad schools, or a street - that looked fine on the listing photos but turns out to be on a motorway. -

-

- One payment. Lifetime access. Less than your survey costs and vastly more useful. -

-
); } diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 3bcaa2c..5c4487f 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -1,5 +1,6 @@ import type { FeatureMeta } from '../../types'; import { InfoIcon } from './icons'; +import { getGroupIcon } from '../../lib/group-icons'; interface FeatureLabelProps { feature: FeatureMeta; @@ -15,11 +16,15 @@ export function FeatureLabel({ size = 'xs', }: FeatureLabelProps) { const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; + const GroupIcon = feature.group ? getGroupIcon(feature.group) : null; return (
+ {GroupIcon && ( + + )} diff --git a/frontend/src/components/ui/SaveSearchModal.tsx b/frontend/src/components/ui/SaveSearchModal.tsx index c8da9e7..0fd7611 100644 --- a/frontend/src/components/ui/SaveSearchModal.tsx +++ b/frontend/src/components/ui/SaveSearchModal.tsx @@ -1,19 +1,23 @@ import { useState, useCallback, useEffect } from 'react'; +import { CheckIcon } from './icons/CheckIcon'; import { CloseIcon } from './icons/CloseIcon'; import { SpinnerIcon } from './icons/SpinnerIcon'; export default function SaveSearchModal({ onClose, onSave, + onViewSearches, saving, error, }: { onClose: () => void; onSave: (name: string) => Promise; + onViewSearches: () => void; saving: boolean; error: string | null; }) { const [name, setName] = useState(''); + const [saved, setSaved] = useState(false); const handleSubmit = useCallback( async (e: React.FormEvent) => { @@ -21,12 +25,12 @@ export default function SaveSearchModal({ if (!name.trim() || saving) return; try { await onSave(name.trim()); - onClose(); + setSaved(true); } catch { // Error displayed in modal } }, - [name, saving, onSave, onClose] + [name, saving, onSave] ); useEffect(() => { @@ -45,7 +49,9 @@ export default function SaveSearchModal({ onClick={(e) => e.stopPropagation()} >
-

Save Search

+

+ {saved ? 'Search saved' : 'Save Search'} +

-
-
- - setName(e.target.value)} - 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="My search" - autoFocus - /> + {saved ? ( +
+
+ +

+ Your search has been saved successfully. +

+
+
+ + +
+ ) : ( + +
+ + setName(e.target.value)} + 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="My search" + autoFocus + /> +
- {error &&

{error}

} + {error &&

{error}

} -
- - -
- +
+ + +
+ + )}
); diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx index 3d84025..04f3f68 100644 --- a/frontend/src/components/ui/UserMenu.tsx +++ b/frontend/src/components/ui/UserMenu.tsx @@ -38,11 +38,29 @@ export default function UserMenu({ {open && (
-

- {user.email} -

+
+

+ {user.email} +

+ + {user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'} + +
+ setOpen(false)} + className="block 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 +