diff --git a/docker-compose.yml b/docker-compose.yml index 4979d0a..eb2f502 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: SCREENSHOT_URL: http://screenshot:8002 GEMINI_API_KEY: AIzaSyC2mQDcEwILHM3uOE2C-lxUQbQrKTX9Xi4 GEMINI_MODEL: gemini-3-flash-preview - PUBLIC_URL: https://perfect-postcodes.co.uk + PUBLIC_URL: http://localhost:3001 GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY" STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3 STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0 diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index 64683bf..ec1fdcb 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -81,9 +81,6 @@ const SAME_AS_EN_VALUE_ALLOWLIST = new Set([ const FORBIDDEN_VISIBLE_STRINGS = [ ['without this filter', 'filters.filtersOut'], ['Connecting to server...', 'common.connectingToServer'], - ['Property saved!', 'toasts.propertySaved'], - ['View saved', 'toasts.viewSaved'], - ["Don't show again", 'toasts.dontShowAgain'], ['Close pane', 'common.closePane'], ['Points of interest', 'poiPane.pointsOfInterest'], ['No data', 'common.noData'], diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 5d8d68c..69a6d77 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -2,7 +2,14 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; -import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api'; +import { + apiUrl, + authHeaders, + assertOk, + shortenUrl, + prewarmScreenshot, + paramsWithLanguage, +} from '../../lib/api'; import { copyToClipboard } from '../../lib/clipboard'; import { formatRelativeTime, formatNumber } from '../../lib/format'; import { summarizeParams } from '../../lib/url-state'; @@ -193,7 +200,7 @@ function SavedSearchesTab({ onUpdateName: (id: string, name: string) => void; onOpen: (params: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [copiedId, setCopiedId] = useState(null); const [sharingId, setSharingId] = useState(null); @@ -213,18 +220,21 @@ function SavedSearchesTab({ const handleShare = useCallback( async (params: string, id: string) => { - prewarmScreenshot(params); + prewarmScreenshot(params, i18n.language); setSharingId(id); try { - const shortUrl = await shortenUrl(params); + const shortUrl = await shortenUrl(params, i18n.language); doCopy(shortUrl, id); } catch { - doCopy(`${window.location.origin}/dashboard?${params}`, id); + doCopy( + `${window.location.origin}/dashboard?${paramsWithLanguage(params, i18n.language)}`, + id + ); } finally { setSharingId(null); } }, - [doCopy] + [doCopy, i18n.language] ); if (loading) { @@ -354,6 +364,36 @@ export function SavedPage({ const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>( window.location.hash === '#shared-links' ? 'shared-links' : 'searches' ); + const [shareLinks, setShareLinks] = useState([]); + const [shareLinksLoading, setShareLinksLoading] = useState(false); + const [shareLinksError, setShareLinksError] = useState(null); + + useEffect(() => { + let cancelled = false; + setShareLinksLoading(true); + setShareLinksError(null); + + fetch(apiUrl('share-links'), authHeaders()) + .then((res) => { + assertOk(res, 'Fetch share links'); + return res.json(); + }) + .then((data: { links: ShareLinkListItem[] }) => { + if (!cancelled) setShareLinks(data.links); + }) + .catch((err) => { + if (!cancelled) { + setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links'); + } + }) + .finally(() => { + if (!cancelled) setShareLinksLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); const tabClass = (tab: string) => `px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ @@ -375,6 +415,11 @@ export function SavedPage({ @@ -388,7 +433,12 @@ export function SavedPage({ onOpen={onOpenSearch} /> ) : ( - + )} ); @@ -505,40 +555,20 @@ function InviteTable({ ); } -function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) { +function ShareLinksSection({ + links, + loading, + error, + showTitle = true, +}: { + links: ShareLinkListItem[]; + loading: boolean; + error: string | null; + showTitle?: boolean; +}) { const { t } = useTranslation(); - const [links, setLinks] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [copiedCode, setCopiedCode] = useState(null); - useEffect(() => { - let cancelled = false; - setLoading(true); - setError(null); - - fetch(apiUrl('share-links'), authHeaders()) - .then((res) => { - assertOk(res, 'Fetch share links'); - return res.json(); - }) - .then((data: { links: ShareLinkListItem[] }) => { - if (!cancelled) setLinks(data.links); - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to fetch share links'); - } - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, []); - const handleCopy = (url: string, code: string) => { copyToClipboard(url, () => { setCopiedCode(code); diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 734a958..a37addb 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,8 +1,13 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ts } from '../../i18n/server'; -import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../../types'; -import type { TravelTimeEntry } from '../../hooks/useTravelTime'; +import type { + FeatureFilters, + FeatureMeta, + FilterExclusion, + HexagonStatsResponse, +} from '../../types'; +import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime'; import type { HexagonLocation } from '../../lib/external-search'; import { formatValue, @@ -61,6 +66,21 @@ function normalizePercentageSegments(segments: T[]) return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] })); } +function filterValueFormat(feature?: FeatureMeta) { + if (!feature) return undefined; + return { + prefix: feature.prefix, + suffix: feature.suffix, + raw: feature.raw, + }; +} + +function formatExclusionPercent(value: number): string { + const percent = value * 100; + if (percent < 10) return `${percent.toFixed(1)}%`; + return `${Math.round(percent)}%`; +} + export default function AreaPane({ stats, globalFeatures, @@ -103,6 +123,36 @@ export default function AreaPane({ () => new Map(globalFeatures.map((f) => [f.name, f])), [globalFeatures] ); + const travelEntryByField = useMemo(() => { + const map = new Map(); + for (const entry of travelTimeEntries ?? []) { + map.set(travelFieldKey(entry), entry); + } + return map; + }, [travelTimeEntries]); + const filterExclusions = stats?.filter_exclusions ?? []; + + const getExclusionLabel = (exclusion: FilterExclusion) => { + const travelEntry = travelEntryByField.get(exclusion.name); + if (travelEntry) return t('areaPane.travelTo', { destination: travelEntry.label }); + return ts(exclusion.name); + }; + + const formatExclusionValue = (exclusion: FilterExclusion, value: number) => { + if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.min')}`; + return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name))); + }; + + const getExclusionAdjustment = (exclusion: FilterExclusion) => { + if (exclusion.direction === 'allow_value') { + return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') }); + } + if (exclusion.value == null) return ''; + const value = formatExclusionValue(exclusion, exclusion.value); + return exclusion.direction === 'lower_min' + ? t('areaPane.lowerMinTo', { value }) + : t('areaPane.raiseMaxTo', { value }); + }; if (!hexagonId) { return ( @@ -205,6 +255,31 @@ export default function AreaPane({ > {t('areaPane.showAllStats')} + {filterExclusions.length > 0 && ( +
+

{t('areaPane.closestBlockingFilters')}

+
    + {filterExclusions.map((exclusion) => ( +
  1. +
    + + {getExclusionLabel(exclusion)} + + + {formatExclusionPercent(exclusion.relative_difference)} + +
    +

    + {getExclusionAdjustment(exclusion)} +

    +
  2. + ))} +
+
+ )} )} {canViewProperties && ( diff --git a/frontend/src/components/map/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx index d0f9681..1355b4f 100644 --- a/frontend/src/components/map/ExternalSearchLinks.tsx +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -5,6 +5,7 @@ import { buildRightmoveExactPostcodeRedirectUrl, buildPropertySearchUrls, H3_RADIUS_MILES, + POSTCODE_RADIUS_MILES, type HexagonLocation, } from '../../lib/external-search'; import outcodeIds from '../../lib/rightmove-outcodes.json'; @@ -36,8 +37,10 @@ export default function ExternalSearchLinks({ if (!location.isPostcode || !location.postcode) return urls.rightmove; return buildRightmoveExactPostcodeRedirectUrl(location.postcode, urls.rightmove); }, [location.isPostcode, location.postcode, urls?.rightmove]); - const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1); - const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`; + const radiusMiles = location.isPostcode + ? POSTCODE_RADIUS_MILES + : (H3_RADIUS_MILES[location.resolution] ?? 1); + const label = `${radiusMiles}mi radius`; if (!urls) return null; diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 7bb4521..3a3bf7d 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -2,6 +2,7 @@ import { memo, useState, useMemo, useRef, useCallback, useEffect, type FormEvent import { useTranslation } from 'react-i18next'; import type { FeatureMeta, FeatureFilters } from '../../types'; +import { findActiveFilterElement } from '../../lib/active-filter-scroll'; import { buildPercentileScale } from '../../lib/format'; import type { PercentileScale } from '../../lib/format'; import InfoPopup from '../ui/InfoPopup'; @@ -486,9 +487,7 @@ export default memo(function Filters({ const name = pendingScrollRef.current; if (!name) return; pendingScrollRef.current = null; - const el = scrollRef.current?.querySelector( - `[data-filter-name="${CSS.escape(name)}"]` - ); + const el = findActiveFilterElement(scrollRef.current, name); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'start' }); diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 6ae7dc4..6647e4d 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -69,6 +69,7 @@ interface MapProps { bounds?: Bounds | null; hideLegend?: boolean; hideLocationSearch?: boolean; + hideTopCardsWhenNarrow?: boolean; travelTimeEntries?: TravelTimeEntry[]; densityLabel?: string; totalCount?: number; @@ -82,6 +83,17 @@ interface Dimensions { height: number; } +const DESKTOP_TOP_CARD_WIDTH = 300; +const DESKTOP_TOP_CARD_GAP = 8; +const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24; +const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH = + DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET; +const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH = + DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET; +const DESKTOP_TOP_CARD_CLASS = 'w-[300px]'; +const DESKTOP_LOCATION_SEARCH_INPUT_CLASS = + 'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'; + type MapContainerStyle = CSSProperties & { '--map-mobile-bottom-inset'?: string; }; @@ -208,6 +220,23 @@ function getRenderedViewState(map: MapRef | null): ViewState | null { }; } +function getRenderedVisibleCenter( + map: MapRef | null, + dimensions: Dimensions, + bottomScreenInset: number +): Pick | null { + if (!map || dimensions.width <= 0 || dimensions.height <= 0) return null; + + const visibleBottomInset = clamp(bottomScreenInset, 0, dimensions.height); + const visibleCenterY = (dimensions.height - visibleBottomInset) / 2; + const center = map.unproject([dimensions.width / 2, visibleCenterY]); + + return { + longitude: center.lng, + latitude: center.lat, + }; +} + function DeckOverlay({ layers, getTooltip, @@ -260,6 +289,7 @@ export default memo(function Map({ bounds: viewportBounds, hideLegend = false, hideLocationSearch = false, + hideTopCardsWhenNarrow = false, travelTimeEntries = EMPTY_TRAVEL_ENTRIES, densityLabel: densityLabelProp, totalCount: totalCountProp, @@ -319,6 +349,9 @@ export default memo(function Map({ const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset); const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight); const resolution = zoomToResolution(renderedViewState.zoom); + const renderedVisibleCenter = + getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ?? + renderedViewState; onViewChange({ resolution, @@ -326,6 +359,8 @@ export default memo(function Map({ zoom: renderedViewState.zoom, latitude: renderedViewState.latitude, longitude: renderedViewState.longitude, + visibleLatitude: renderedVisibleCenter.latitude, + visibleLongitude: renderedVisibleCenter.longitude, }); }; frame = window.requestAnimationFrame(emit); @@ -389,6 +424,19 @@ export default memo(function Map({ () => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}), [bottomScreenInset] ); + const hideDesktopTopCardsForWidth = + hideTopCardsWhenNarrow && + dimensions.width > 0 && + dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH; + const stackDesktopTopCards = + hideTopCardsWhenNarrow && + dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH && + dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH; + const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth; + const showLegend = !hideLegend && !hideDesktopTopCardsForWidth; + const desktopTopCardsLayoutClass = stackDesktopTopCards + ? 'flex-col items-start' + : 'items-start justify-between'; const { layers, @@ -452,11 +500,11 @@ export default memo(function Map({
{/* Center: Logo card with hero text */}
-
- +
+ {t('map.ogTitle')} @@ -494,74 +542,83 @@ export default memo(function Map({ ) : null ) : ( <> -
- {!hideLocationSearch && ( - - )} - {!hideLegend && - (viewFeature && colorRange ? ( - viewFeature.startsWith('tt_') ? ( - - ) : colorFeatureMeta ? ( - - ) : null - ) : ( - + {showLocationSearch && ( + - ))} -
+ )} + {showLegend && + (viewFeature && colorRange ? ( + viewFeature.startsWith('tt_') ? ( + + ) : colorFeatureMeta ? ( + + ) : null + ) : ( + + ))} +
+ )} {popupInfo && (
+
{feature.detail && onShowInfo && (showText ? ( diff --git a/frontend/src/components/ui/UpgradeModal.tsx b/frontend/src/components/ui/UpgradeModal.tsx index e4a20dd..cb99ddb 100644 --- a/frontend/src/components/ui/UpgradeModal.tsx +++ b/frontend/src/components/ui/UpgradeModal.tsx @@ -59,7 +59,7 @@ export default function UpgradeModal({ }; return ( -
+
{/* Close button */}