diff --git a/Dockerfile.finder b/Dockerfile.finder new file mode 100644 index 0000000..52ef745 --- /dev/null +++ b/Dockerfile.finder @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app +COPY finder/pyproject.toml ./ +RUN uv pip install --system -r pyproject.toml + +COPY finder/*.py ./ +COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet + +CMD ["python3", "main.py"] diff --git a/Makefile.data b/Makefile.data index 0b10525..278c8bd 100644 --- a/Makefile.data +++ b/Makefile.data @@ -1,7 +1,7 @@ -# Data pipeline — download sources and build wide.parquet +# Data pipeline — download sources and build postcode.parquet + properties.parquet # # Usage: -# make -f Makefile.data prepare # Build wide.parquet (+ all deps) +# make -f Makefile.data prepare # Build all parquets (+ all deps) # make -f Makefile.data tiles # Download UK map tiles # # Or include from the main Makefile and use targets directly. @@ -22,7 +22,9 @@ POIS_RAW := $(DATA_DIR)/uk_pois.parquet POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet EPC_PP := $(DATA_DIR)/epc_pp.parquet -WIDE := $(DATA_DIR)/wide.parquet +POSTCODES_PQ := $(DATA_DIR)/postcode.parquet +PROPERTIES_PQ := $(DATA_DIR)/properties.parquet +MERGE_STAMP := $(DATA_DIR)/.merge_done PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/certificates.csv @@ -48,9 +50,8 @@ TRANSIT_STAMP := $(TRANSIT_DIR)/.done GREENSPACE := $(DATA_DIR)/greenspace_water.parquet PBF := $(DATA_DIR)/great-britain-latest.osm.pbf PLACES := $(DATA_DIR)/places.parquet -RIGHTMOVE_BUY := $(DATA_DIR)/rightmove_buy.parquet -RIGHTMOVE_RENT := $(DATA_DIR)/rightmove_rent.parquet -ONLINE_STAMP := $(DATA_DIR)/.online_done +LISTINGS_BUY := $(DATA_DIR)/online_listings_buy.parquet +LISTINGS_RENT := $(DATA_DIR)/online_listings_rent.parquet # Sentinel files for directory targets (Make doesn't track directories well) GEOSURE_STAMP := $(GEOSURE_DIR)/.done @@ -60,7 +61,7 @@ PMTILES_VERSION := 1.22.3 # ── Phony aliases ───────────────────────────────────────────────────────────── -.PHONY: prepare wide tiles \ +.PHONY: prepare merge tiles \ download-arcgis download-price-paid download-deprivation download-ethnicity \ download-naptan download-pois download-ofsted download-broadband download-rental-prices \ download-postcodes download-geosure download-noise download-inspire \ @@ -70,8 +71,8 @@ PMTILES_VERSION := 1.22.3 generate-postcode-boundaries \ journey-times -prepare: $(DATA_DIR)/.prices_done -wide: $(WIDE) +prepare: $(PRICES_STAMP) +merge: $(MERGE_STAMP) tiles: $(TILES) download-arcgis: $(ARCGIS) download-price-paid: $(PRICE_PAID) @@ -253,10 +254,10 @@ $(PC_BOUNDARIES): @echo "" @exit 1 -# ── Final merge ─────────────────────────────────────────────────────────────── +# ── Final merge → postcode.parquet + properties.parquet ────────────────────── -$(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ - $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL) +$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ + $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL) uv run python -m pipeline.transform.merge \ --epc-pp $(EPC_PP) \ --arcgis $(ARCGIS) \ @@ -271,22 +272,15 @@ $(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) --broadband $(BROADBAND) \ --geosure $(GEOSURE) \ --rental-prices $(RENTAL) \ - --output $@ - -# ── Online listings (post-merge, pre-pricing) ─────────────────────────────── - -$(ONLINE_STAMP): $(WIDE) $(RIGHTMOVE_BUY) $(RIGHTMOVE_RENT) - uv run python -m pipeline.transform.add_online_listings \ - --input $(WIDE) \ - --buy $(RIGHTMOVE_BUY) \ - --rent $(RIGHTMOVE_RENT) + --output-postcodes $(POSTCODES_PQ) \ + --output-properties $(PROPERTIES_PQ) @touch $@ -# ── Price estimation (post-merge + online) ────────────────────────────────── +# ── Price estimation (post-merge) ─────────────────────────────────────────── -$(PRICE_INDEX): $(ONLINE_STAMP) - uv run python -m pipeline.transform.price_estimation.index --input $(WIDE) --output $@ +$(PRICE_INDEX): $(MERGE_STAMP) + uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@ -$(PRICES_STAMP): $(ONLINE_STAMP) $(PRICE_INDEX) - uv run python -m pipeline.transform.price_estimation.estimate --input $(WIDE) --index $(PRICE_INDEX) +$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) + uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) @touch $@ diff --git a/README.md b/README.md index 5464f58..4bdcb70 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,8 @@ https://xploria.co.uk/data-sources/ --- -- fix frontend - - map hexagons - stripe -- update texts -- move data to raid -- extract all user-facing texts into a yaml file for easy editing -- register for email - -FAQ: - Why hexagons? - Why the price tag? @@ -50,54 +42,10 @@ FAQ: make -f Makefile.data prepare make -f Makefile.data tiles - - - -## outstadning prompts - - -Add licensing to the app. By default, anonymous users can use the map but only in central london. if they try zooming out, the server refuses to provide data and the users will be prompted to buy a lifetime license to continue (or zoom back in). Just before buying a license, they have to register by providing their email address and password, then they need to complete the stripe check out workflow. Implement the full pocketbase/server/frontend integration. For admins, give an option to generate an invite link, opening which prompts you to register and gives you a free license forever. Have a cool animation with party poppers on the successful acquiring of a license. For non-admin users, allow inviting friends for 30% off the price. Also add a support page that shows my email address, and add a FAQ on the same page too. - -- -- add blue/green rollout - - - - -- fully remove the AI summary - -- show all the info about listings - -- put the travel times into categories - -- move the support page under Learn and merge the FAQs - -- add an account page and merge it with Saved - - padding between email and resend verification -- rename historical/bu/rent - -- the hero page with the floating card is ugly, make them better integrated - - make the active filters much bigger on the demo page -- make the pricing tiers different cards next to each other - -- hide the pricing if you're logged in - -- make the loading more obvious and in the middle of the map - -- the typeahead no longer works. I see the requests but nothing shows up - -- make hero 100% height - -- unsquish hexagons - -- Get lifetime access - -- "let's see an example with arrow down" - - make demo filters adjustable - add next button to cards @@ -108,18 +56,44 @@ Add licensing to the app. By default, anonymous users can use the map but only - start epxloring should bring to dashboard -- get lifetime access should say you have it already - -- density -> number properties - -- make price history a rolling avg - -- zoom in at the end of the demo - - referal link is broken -- remove random wales/ir/scotland areas from rightmove - - load test -- saved search Only superusers can perform this action. \ No newline at end of file +imrpove walkthrough + +load tests with grafana + +house reposession + +## execution + +enum colour coding + +Better school searchs + +save -> dashboard + +fix links to markets, + +404, + +Jittery slider number label + +Odd vertical spacing on mobile + +Show even number of cards on mobile + +Construction age is spaced oit + +Make prop density smaller + +Test on safari + +Test on android + + +check rendered index html, + + +only support new finder.py parquet type \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 20ab5f4..8ebd434 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: command: > bash -c " cargo install cargo-watch && - cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times' + cargo watch -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.parquet --listings-buy /app/data/online_listings_buy.parquet --listings-rent /app/data/online_listings_rent.parquet --pois /app/data/filtered_uk_pois.parquet --places /app/data/places.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries --travel-times /app/data/travel-times' " ports: - "8001:8001" @@ -38,9 +38,9 @@ services: STRIPE_REFERRAL_COUPON_ID: L5uQqagl GOOGLE_OAUTH_CLIENT_ID: 536485512604-740bbn3tf027ogrdcr5sqor4ntorkaqv.apps.googleusercontent.com GOOGLE_OAUTH_CLIENT_SECRET: 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 - APPLE_OAUTH_CLIENT_ID: ${APPLE_OAUTH_CLIENT_ID} - APPLE_OAUTH_CLIENT_SECRET: ${APPLE_OAUTH_CLIENT_SECRET} depends_on: + screenshot: + condition: service_healthy pocketbase: condition: service_healthy @@ -107,46 +107,46 @@ services: retries: 3 start_period: 5s - gluetun: - image: qmcgaw/gluetun:v3.40.4 - volumes: - - gluetun-cache-v2:/gluetun - - gluetun-auth:/gluetun/auth:ro - environment: - # See https://github.com/qdm12/gluetun-wiki/tree/main/setup#setup - VPN_SERVICE_PROVIDER: mullvad - VPN_TYPE: wireguard - WIREGUARD_PRIVATE_KEY: "8FFKmtTvDsZlShnKl/opDDwCwb9v2ox4+Kkl3wX+9Gw=" - WIREGUARD_ADDRESSES: "10.66.109.86/32" - OWNED_ONLY: "yes" - UPDATER_PERIOD: 24h - SERVER_COUNTRIES: Serbia,Slovakia,Croatia,Austria,Denmark,Finland - TZ: $TIME_ZONE - restart: unless-stopped - ports: - - "1234:1234" - healthcheck: - test: "wget -q https://www.google.com || exit 1" - interval: 1m - timeout: 15s - retries: 2 - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun:/dev/net/tun + # gluetun: + # image: qmcgaw/gluetun:v3.40.4 + # volumes: + # - gluetun-cache-v2:/gluetun + # - gluetun-auth:/gluetun/auth:ro + # environment: + # # See https://github.com/qdm12/gluetun-wiki/tree/main/setup#setup + # VPN_SERVICE_PROVIDER: mullvad + # VPN_TYPE: wireguard + # WIREGUARD_PRIVATE_KEY: "8FFKmtTvDsZlShnKl/opDDwCwb9v2ox4+Kkl3wX+9Gw=" + # WIREGUARD_ADDRESSES: "10.66.109.86/32" + # OWNED_ONLY: "yes" + # UPDATER_PERIOD: 24h + # SERVER_COUNTRIES: Serbia,Slovakia,Croatia,Austria,Denmark,Finland + # TZ: $TIME_ZONE + # restart: unless-stopped + # ports: + # - "1234:1234" + # healthcheck: + # test: "wget -q https://www.google.com || exit 1" + # interval: 1m + # timeout: 15s + # retries: 2 + # cap_add: + # - NET_ADMIN + # devices: + # - /dev/net/tun:/dev/net/tun - - finder: - build: ./finder - init: true - network_mode: service:gluetun - volumes: - - ./finder:/app - - ./property-data/arcgis_data.parquet:/data/arcgis_data.parquet:ro - depends_on: - gluetun: - condition: service_healthy - restart: unless-stopped + # finder: + # build: + # context: . + # dockerfile: Dockerfile.finder + # init: true + # network_mode: service:gluetun + # volumes: + # - ./finder:/app + # depends_on: + # gluetun: + # condition: service_healthy + # restart: unless-stopped volumes: diff --git a/finder/Dockerfile b/finder/Dockerfile index 84c7a4f..267a60d 100644 --- a/finder/Dockerfile +++ b/finder/Dockerfile @@ -7,5 +7,6 @@ COPY pyproject.toml ./ RUN uv pip install --system -r pyproject.toml COPY *.py ./ +COPY property-data/arcgis_data.parquet /data/arcgis_data.parquet CMD ["python3", "main.py"] diff --git a/finder/constants.py b/finder/constants.py index c5c73be..04003aa 100644 --- a/finder/constants.py +++ b/finder/constants.py @@ -11,6 +11,11 @@ RETRY_BASE_DELAY = 2.0 GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index SEED = 42 +# Schedule: hour of day (UTC) to auto-run scrape. Set to -1 to disable. +SCHEDULE_HOUR = int(os.environ.get("SCHEDULE_HOUR", "3")) +# Whether to run a scrape immediately on startup +RUN_ON_STARTUP = os.environ.get("RUN_ON_STARTUP", "true").lower() in ("1", "true", "yes") + TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead" SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search" RIGHTMOVE_BASE = "https://www.rightmove.co.uk" diff --git a/finder/data/rightmove.log b/finder/data/rightmove.log index 594f589..1b961e0 100644 --- a/finder/data/rightmove.log +++ b/finder/data/rightmove.log @@ -28329,3 +28329,27 @@ * Running on http://127.0.0.1:1234 * Running on http://10.66.109.86:1234 2026-02-15 22:37:52,025 [INFO] Press CTRL+C to quit +2026-02-15 23:00:08,987 [INFO] Loading arcgis data... +2026-02-15 23:00:08,988 [INFO] Loading outcodes from /data/arcgis_data.parquet +2026-02-15 23:00:09,078 [INFO] England postcodes: 2260065 +2026-02-15 23:00:09,118 [INFO] Unique England outcodes: 2323 +2026-02-15 23:00:09,118 [INFO] Building postcode spatial index from /data/arcgis_data.parquet +2026-02-15 23:00:10,418 [INFO] Postcode spatial index: 113226 cells, 2260065 postcodes +2026-02-15 23:00:10,434 [INFO] Ready — 2323 outcodes, postcode index built +2026-02-15 23:00:10,446 [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:1234 + * Running on http://10.66.109.86:1234 +2026-02-15 23:00:10,446 [INFO] Press CTRL+C to quit +2026-02-16 19:56:56,857 [INFO] Loading arcgis data... +2026-02-16 19:56:56,857 [INFO] Loading outcodes from /data/arcgis_data.parquet +2026-02-16 19:56:57,061 [INFO] England postcodes: 2260065 +2026-02-16 19:56:57,161 [INFO] Unique England outcodes: 2323 +2026-02-16 19:56:57,162 [INFO] Building postcode spatial index from /data/arcgis_data.parquet +2026-02-16 19:57:00,146 [INFO] Postcode spatial index: 113226 cells, 2260065 postcodes +2026-02-16 19:57:00,227 [INFO] Ready — 2323 outcodes, postcode index built +2026-02-16 19:57:00,247 [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:1234 + * Running on http://10.66.109.86:1234 +2026-02-16 19:57:00,248 [INFO] Press CTRL+C to quit diff --git a/finder/main.py b/finder/main.py index bb166be..114cf57 100644 --- a/finder/main.py +++ b/finder/main.py @@ -1,12 +1,13 @@ import logging import threading import time +from datetime import datetime, timedelta, timezone from pathlib import Path from flask import Flask, Response, jsonify, send_from_directory from prometheus_client import generate_latest, CONTENT_TYPE_LATEST -from constants import DATA_DIR +from constants import DATA_DIR, RUN_ON_STARTUP, SCHEDULE_HOUR from rightmove import outcode_cache from scraper import ( _sync_gauges, @@ -46,6 +47,52 @@ OUTCODES = load_outcodes() PC_INDEX = build_postcode_index() log.info("Ready — %d outcodes, postcode index built", len(OUTCODES)) +# --------------------------------------------------------------------------- +# Scheduler +# --------------------------------------------------------------------------- + + +def _start_scrape() -> bool: + """Try to start a scrape. Returns True if started, False if already running.""" + with status_lock: + if status.state == "running": + return False + status.state = "running" + thread = threading.Thread(target=run_scrape, args=(OUTCODES, PC_INDEX), daemon=True) + thread.start() + return True + + +def _seconds_until(hour: int) -> float: + """Seconds from now until the next occurrence of `hour`:00 UTC.""" + now = datetime.now(timezone.utc) + target = now.replace(hour=hour, minute=0, second=0, microsecond=0) + if target <= now: + target += timedelta(days=1) + return (target - now).total_seconds() + + +def _scheduler_loop() -> None: + """Background thread that triggers a daily scrape at SCHEDULE_HOUR UTC.""" + log.info("Scheduler active — will run daily at %02d:00 UTC", SCHEDULE_HOUR) + while True: + wait = _seconds_until(SCHEDULE_HOUR) + log.info("Next scheduled scrape in %.0f seconds (%.1f hours)", wait, wait / 3600) + time.sleep(wait) + log.info("Scheduled scrape triggered") + if not _start_scrape(): + log.warning("Scheduled scrape skipped — already running") + + +if RUN_ON_STARTUP: + log.info("RUN_ON_STARTUP=true — starting initial scrape") + _start_scrape() + +if SCHEDULE_HOUR >= 0: + scheduler = threading.Thread(target=_scheduler_loop, daemon=True) + scheduler.start() + + # --------------------------------------------------------------------------- # Flask app # --------------------------------------------------------------------------- @@ -55,14 +102,9 @@ app = Flask(__name__) @app.route("/run", methods=["POST"]) def trigger_run(): - with status_lock: - if status.state == "running": - return jsonify({"error": "Scrape already running"}), 409 - status.state = "running" - - thread = threading.Thread(target=run_scrape, args=(OUTCODES, PC_INDEX), daemon=True) - thread.start() - return jsonify({"message": "Scrape started"}), 200 + if _start_scrape(): + return jsonify({"message": "Scrape started"}), 200 + return jsonify({"error": "Scrape already running"}), 409 @app.route("/status") @@ -72,7 +114,7 @@ def get_status(): if status.started_at: end = status.finished_at if status.finished_at else time.time() elapsed = end - status.started_at - return jsonify({ + resp = { "state": status.state, "channel": status.channel, "outcode": status.outcode, @@ -82,7 +124,10 @@ def get_status(): "properties_rent": status.properties_rent, "errors": status.errors[-20:], # last 20 errors "elapsed_seconds": round(elapsed, 1), - }) + } + if SCHEDULE_HOUR >= 0: + resp["next_scrape_in_seconds"] = round(_seconds_until(SCHEDULE_HOUR)) + return jsonify(resp) @app.route("/debug") diff --git a/finder/scraper.py b/finder/scraper.py index cc80441..1e3f00d 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -159,8 +159,8 @@ def run_scrape(outcodes: list[str], pc_index: PostcodeSpatialIndex) -> None: # Write parquet deduped = list(all_properties.values()) - output_path = DATA_DIR / f"rightmove_{file_suffix}.parquet" - write_parquet(deduped, output_path) + output_path = DATA_DIR / f"online_listings_{file_suffix}.parquet" + write_parquet(deduped, output_path, channel=file_suffix) with status_lock: if channel_name == "BUY": diff --git a/finder/storage.py b/finder/storage.py index d2ddb96..c2b9f81 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -1,63 +1,94 @@ import logging +from datetime import datetime from pathlib import Path import polars as pl +from transform import normalize_price + log = logging.getLogger("rightmove") -def write_parquet(properties: list[dict], path: Path) -> None: - """Write properties list to parquet using Polars.""" +def write_parquet(properties: list[dict], path: Path, channel: str) -> None: + """Write properties list to parquet with server-ready column names. + + channel: "buy" or "rent" + """ if not properties: log.warning("No properties to write to %s", path) return + # Parse first_visible_date to datetime + listing_dates = [] + for p in properties: + fvd = p.get("first_visible_date", "") + if fvd: + try: + dt = datetime.fromisoformat(fvd.replace("Z", "+00:00")) + listing_dates.append(dt.replace(tzinfo=None)) + except (ValueError, TypeError): + listing_dates.append(None) + else: + listing_dates.append(None) + + # Derive asking price / asking rent based on channel + if channel == "buy": + asking_prices = [p["price"] for p in properties] + asking_rents = [None] * len(properties) + listing_statuses = ["For sale"] * len(properties) + else: + asking_prices = [None] * len(properties) + asking_rents = [ + normalize_price(p["price"], p["price_frequency"]) for p in properties + ] + listing_statuses = ["For rent"] * len(properties) + df = pl.DataFrame( { - "id": [p["id"] for p in properties], - "bedrooms": [p["bedrooms"] for p in properties], - "bathrooms": [p["bathrooms"] for p in properties], - "total_rooms": [p["total_rooms"] for p in properties], - "longitude": [p["longitude"] for p in properties], - "latitude": [p["latitude"] for p in properties], - "postcode": [p["postcode"] for p in properties], - "address": [p["address"] for p in properties], - "tenure": [p["tenure"] for p in properties], - "property_type": [p["property_type"] for p in properties], - "property_sub_type": [p["property_sub_type"] for p in properties], - "price": [p["price"] for p in properties], - "price_frequency": [p["price_frequency"] for p in properties], - "price_qualifier": [p["price_qualifier"] for p in properties], - "floorspace_sqm": [p["floorspace_sqm"] for p in properties], - "url": [p["url"] for p in properties], - "features": [p["features"] for p in properties], - "first_visible_date": [p["first_visible_date"] for p in properties], - "update_date": [p["update_date"] for p in properties], - "outcode": [p["outcode"] for p in properties], - "house_share": [p["house_share"] for p in properties], + "Bedrooms": [p["Bedrooms"] for p in properties], + "Bathrooms": [p["Bathrooms"] for p in properties], + "Number of bedrooms & living rooms": [ + p["Number of bedrooms & living rooms"] for p in properties + ], + "lon": [p["lon"] for p in properties], + "lat": [p["lat"] for p in properties], + "Postcode": [p["Postcode"] for p in properties], + "Address per Property Register": [ + p["Address per Property Register"] for p in properties + ], + "Leashold/Freehold": [p["Leashold/Freehold"] for p in properties], + "Property type": [p["Property type"] for p in properties], + "Property sub-type": [p["Property sub-type"] for p in properties], + "Price qualifier": [p["Price qualifier"] for p in properties], + "Total floor area (sqm)": [ + p["Total floor area (sqm)"] for p in properties + ], + "Listing URL": [p["Listing URL"] for p in properties], + "Listing features": [p["Listing features"] for p in properties], + "Listing date": listing_dates, + "Listing status": listing_statuses, + "Asking price": asking_prices, + "Asking rent (monthly)": asking_rents, }, schema={ - "id": pl.Int64, - "bedrooms": pl.Int32, - "bathrooms": pl.Int32, - "total_rooms": pl.Int32, - "longitude": pl.Float64, - "latitude": pl.Float64, - "postcode": pl.Utf8, - "address": pl.Utf8, - "tenure": pl.Utf8, - "property_type": pl.Utf8, - "property_sub_type": pl.Utf8, - "price": pl.Int64, - "price_frequency": pl.Utf8, - "price_qualifier": pl.Utf8, - "floorspace_sqm": pl.Float64, - "url": pl.Utf8, - "features": pl.List(pl.Utf8), - "first_visible_date": pl.Utf8, - "update_date": pl.Utf8, - "outcode": pl.Utf8, - "house_share": pl.Boolean, + "Bedrooms": pl.Int32, + "Bathrooms": pl.Int32, + "Number of bedrooms & living rooms": pl.Int32, + "lon": pl.Float64, + "lat": pl.Float64, + "Postcode": pl.Utf8, + "Address per Property Register": pl.Utf8, + "Leashold/Freehold": pl.Utf8, + "Property type": pl.Utf8, + "Property sub-type": pl.Utf8, + "Price qualifier": pl.Utf8, + "Total floor area (sqm)": pl.Float64, + "Listing URL": pl.Utf8, + "Listing features": pl.List(pl.Utf8), + "Listing date": pl.Datetime("us"), + "Listing status": pl.Utf8, + "Asking price": pl.Int64, + "Asking rent (monthly)": pl.Int64, }, ) diff --git a/finder/transform.py b/finder/transform.py index ba3a2a9..f513dd7 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -98,27 +98,27 @@ def transform_property(prop: dict, outcode: str, pc_index: PostcodeSpatialIndex) update_date = listing_update.get("listingUpdateDate", "") postcode = pc_index.nearest(lat, lng) + if not postcode: + log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng) + return None return { "id": prop.get("id"), - "bedrooms": bedrooms, - "bathrooms": bathrooms, - "total_rooms": bedrooms + bathrooms, - "longitude": lng, - "latitude": lat, - "postcode": postcode, - "address": prop.get("displayAddress", ""), - "tenure": extract_tenure(prop.get("tenure")), - "property_type": map_property_type(sub_type), - "property_sub_type": sub_type or "Unknown", + "Bedrooms": bedrooms, + "Bathrooms": bathrooms, + "Number of bedrooms & living rooms": bedrooms + bathrooms, + "lon": lng, + "lat": lat, + "Postcode": postcode, + "Address per Property Register": prop.get("displayAddress", ""), + "Leashold/Freehold": extract_tenure(prop.get("tenure")), + "Property type": map_property_type(sub_type), + "Property sub-type": sub_type or "Unknown", "price": price, "price_frequency": frequency, - "price_qualifier": price_qualifier, - "floorspace_sqm": parse_display_size(prop.get("displaySize")), - "url": RIGHTMOVE_BASE + prop.get("propertyUrl", ""), - "features": key_features, + "Price qualifier": price_qualifier, + "Total floor area (sqm)": parse_display_size(prop.get("displaySize")), + "Listing URL": RIGHTMOVE_BASE + prop.get("propertyUrl", ""), + "Listing features": key_features, "first_visible_date": prop.get("firstVisibleDate", ""), - "update_date": update_date, - "outcode": outcode, - "house_share": sub_type == "House Share", } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b77ffe..5c02cc0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,11 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import MapPage, { type ExportState } from './components/map/MapPage'; import PricingPage from './components/pricing/PricingPage'; import HomePage from './components/home/HomePage'; -import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; import LearnPage from './components/learn/LearnPage'; import AccountPage from './components/account/AccountPage'; import InvitePage from './components/invite/InvitePage'; -import SupportPage from './components/support/SupportPage'; import Header, { type Page } from './components/ui/Header'; import AuthModal from './components/ui/AuthModal'; import SaveSearchModal from './components/ui/SaveSearchModal'; @@ -31,8 +29,6 @@ function pageToPath(page: Page, inviteCode?: string): string { switch (page) { case 'dashboard': return '/dashboard'; - case 'saved-searches': - return '/saved'; case 'learn': return '/learn'; case 'pricing': @@ -41,8 +37,6 @@ function pageToPath(page: Page, inviteCode?: string): string { return '/account'; case 'invite': return `/invite/${inviteCode || ''}`; - case 'support': - return '/support'; default: return '/'; } @@ -50,11 +44,11 @@ function pageToPath(page: Page, inviteCode?: string): string { function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null { if (pathname === '/dashboard') return { page: 'dashboard' }; - if (pathname === '/saved') return { page: 'saved-searches' }; + if (pathname === '/saved') return { page: 'account' }; if (pathname === '/learn') return { page: 'learn' }; if (pathname === '/pricing') return { page: 'pricing' }; if (pathname === '/account') return { page: 'account' }; - if (pathname === '/support') return { page: 'support' }; + if (pathname === '/support') return { page: 'learn' }; if (pathname.startsWith('/invite/')) { const code = pathname.slice('/invite/'.length); return { page: 'invite', inviteCode: code }; @@ -79,12 +73,9 @@ export default function App() { return params.get('og') === '1'; }, []); - // Core data const [features, setFeatures] = useState([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [initialLoading, setInitialLoading] = useState(true); - - // UI state const [pendingInfoFeature, setPendingInfoFeature] = useState(null); const [inviteCode, setInviteCode] = useState(null); const [activePage, setActivePage] = useState(() => { @@ -100,7 +91,6 @@ export default function App() { return 'home'; }); - // Initialize invite code from URL useEffect(() => { const fromPath = pathToPage(window.location.pathname); if (fromPath?.inviteCode) { @@ -128,7 +118,6 @@ export default function App() { const [showLicenseSuccess, setShowLicenseSuccess] = useState(false); const [verificationDismissed, setVerificationDismissed] = useState(false); - // Handle license_success query param (redirect from Stripe) useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('license_success') === '1') { @@ -145,7 +134,6 @@ export default function App() { const savedSearches = useSavedSearches(user?.id ?? null); const [showSaveModal, setShowSaveModal] = useState(false); - // Load features and POI categories on mount useEffect(() => { const controller = new AbortController(); let featuresLoaded = false; @@ -181,9 +169,6 @@ export default function App() { return () => controller.abort(); }, []); - // Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads - - // Navigation const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); @@ -219,14 +204,25 @@ export default function App() { return () => window.removeEventListener('popstate', handlePopState); }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Fetch saved searches when page becomes active const { fetchSearches } = savedSearches; useEffect(() => { - if (activePage === 'saved-searches') { + if (activePage === 'account') { fetchSearches(); } }, [activePage, fetchSearches]); + useEffect(() => { + if (authLoading) return; + if (activePage === 'account' && !user) { + setAuthModalTab('login'); + setShowAuthModal(true); + navigateTo('home'); + } + if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) { + navigateTo('dashboard'); + } + }, [activePage, user, authLoading, navigateTo]); + const [exportState, setExportState] = useState(null); if (isScreenshotMode) { @@ -241,8 +237,8 @@ export default function App() { initialLoading={initialLoading} theme={theme} pendingInfoFeature={null} - onClearPendingInfoFeature={() => {}} - onNavigateTo={() => {}} + onClearPendingInfoFeature={() => { }} + onNavigateTo={() => { }} screenshotMode ogMode={isOgMode} initialTravelTime={urlState.travelTime} @@ -273,7 +269,7 @@ export default function App() { onLogout={logout} isMobile={isMobile} /> - {user && !user.verified && !verificationDismissed && ( + {user && !user.verified && !verificationDismissed && activePage === 'account' && ( )} {activePage === 'home' ? ( - navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} /> - ) : activePage === 'pricing' ? ( + navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} /> + ) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? ( navigateTo('dashboard')} user={user} @@ -298,9 +294,17 @@ export default function App() { ) : activePage === 'learn' ? ( ) : activePage === 'account' && user ? ( - - ) : activePage === 'support' ? ( - + { + window.location.href = `/?${params}`; + }} + /> ) : activePage === 'invite' && inviteCode ? ( - ) : activePage === 'saved-searches' ? ( - { - window.location.href = `/?${params}`; - }} - /> ) : ( = { licensed: 'Licensed', }; -export default function AccountPage({ +function SavedSearchesContent({ + searches, + loading, + onDelete, + onOpen, +}: { + searches: SavedSearch[]; + loading: boolean; + onDelete: (id: string) => Promise; + onOpen: (params: string) => void; +}) { + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const [sharingId, setSharingId] = useState(null); + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteConfirmId) return; + await onDelete(deleteConfirmId); + setDeleteConfirmId(null); + }, [deleteConfirmId, onDelete]); + + const copyToClipboard = useCallback((text: string, id: string) => { + const onSuccess = () => { + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(onSuccess); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + onSuccess(); + } + }, []); + + const handleShare = useCallback(async (params: string, id: string) => { + setSharingId(id); + try { + const shortUrl = await shortenUrl(params); + copyToClipboard(shortUrl, id); + } catch { + copyToClipboard(`${window.location.origin}/?${params}`, id); + } finally { + setSharingId(null); + } + }, [copyToClipboard]); + + return ( + <> + {loading ? ( +
+ +
+ ) : searches.length === 0 ? ( +
+ +

+ No saved searches yet +

+

+ Save your dashboard filters and view to quickly return to them later. +

+
+ ) : ( +
+ {searches.map((search) => ( +
+ {search.screenshotUrl ? ( + {search.name} + ) : ( +
+ +
+ )} + +
+

+ {search.name} +

+

+ {formatRelativeTime(search.created)} +

+

+ {summarizeParams(search.params)} +

+ +
+ + + +
+
+
+ ))} +
+ )} + + {/* Delete confirmation dialog */} + {deleteConfirmId && ( +
setDeleteConfirmId(null)} + > +
+
e.stopPropagation()} + > +
+

Delete search

+ +
+

+ Are you sure you want to delete this saved search? This cannot be undone. +

+
+ + +
+
+
+ )} + + ); +} + +function SettingsContent({ user, onRefreshAuth, onRequestVerification, @@ -102,180 +284,257 @@ export default function AccountPage({ const isLicensed = user.subscription === 'licensed' || user.isAdmin; return ( -
-
-

Account

- -
- {/* Email */} -
-
-

Email

-

{user.email}

-
-
- {!user.verified && ( - - )} - - {user.verified ? 'Verified' : 'Unverified'} - -
+
+
+ {/* Email */} +
+
+

Email

+

{user.email}

- - {/* Subscription */} -
-
-

Subscription

- - {SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'} - -
-
- - {/* Newsletter */} -
-
- {/* Invite friends */} - {isLicensed && ( -
-

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

- {inviteUrl ? ( -
- - -
- ) : ( - - )} - {inviteError && ( -

{inviteError}

- )} -
- )} + {/* Subscription */} +
+
+

Subscription

+ + {SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'} + +
+
- {/* Admin section */} - {user.isAdmin && ( -
-

- Admin: Change subscription -

-
- - -
- {error && ( -

{error}

- )} -
+ {/* Newsletter */} +
+ + {newsletterError && ( +

{newsletterError}

)}
+ + {/* Invite friends */} + {isLicensed && ( +
+

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

+ {inviteUrl ? ( +
+ + +
+ ) : ( + + )} + {inviteError && ( +

{inviteError}

+ )} +
+ )} + + {/* Admin section */} + {user.isAdmin && ( +
+

+ Admin: Change subscription +

+
+ + +
+ {error && ( +

{error}

+ )} +
+ )} +
+
+ ); +} + +export default function AccountPage({ + user, + onRefreshAuth, + onRequestVerification, + searches, + searchesLoading, + onDeleteSearch, + onOpenSearch, +}: { + user: AuthUser; + onRefreshAuth: () => Promise; + onRequestVerification: (email: string) => Promise; + searches: SavedSearch[]; + searchesLoading: boolean; + onDeleteSearch: (id: string) => Promise; + onOpenSearch: (params: string) => void; +}) { + const [activeTab, setActiveTab] = useState(() => { + const hash = window.location.hash.slice(1); + return hash === 'settings' ? 'settings' : 'saved'; + }); + + // Sync hash with tab + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.slice(1); + if (hash === 'settings') setActiveTab('settings'); + else setActiveTab('saved'); + }; + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + const switchTab = (tab: AccountTab) => { + setActiveTab(tab); + window.history.replaceState( + window.history.state, + '', + `/account#${tab}` + ); + }; + + return ( +
+
+

Account

+ + {/* Tabs */} +
+ switchTab('saved')} + /> + switchTab('settings')} + /> +
+ + {/* Tab content */} + {activeTab === 'saved' ? ( + + ) : ( + + )}
); diff --git a/frontend/src/components/faq/FAQPage.tsx b/frontend/src/components/faq/FAQPage.tsx deleted file mode 100644 index 90ac142..0000000 --- a/frontend/src/components/faq/FAQPage.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { ChevronIcon } from '../ui/icons/ChevronIcon'; - -interface FAQItem { - question: string; - answer: string; -} - -const FAQ_ITEMS: FAQItem[] = [ - { - question: 'What is this application?', - answer: - 'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.', - }, - { - question: 'Where does the data come from?', - answer: - 'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources page for full details and links.', - }, - { - question: 'What are the coloured hexagons on the map?', - answer: - 'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.', - }, - { - question: 'How do filters work?', - answer: - 'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.', - }, - { - question: 'What does the eye icon do on a filter?', - answer: - "The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.", - }, - { - question: 'How fresh is the data?', - answer: - 'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 2023–2025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.', - }, - { - question: 'How are EPC records matched to Land Registry sales?', - answer: - "EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.", - }, - { - question: 'What are Points of Interest (POIs)?', - answer: - 'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.', - }, - { - question: 'Can I share a specific view with someone?', - answer: - 'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.', - }, - { - question: 'How do I see individual properties?', - answer: - 'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.', - }, - { - question: 'Why are some hexagons grey?', - answer: - 'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.', - }, - { - question: 'Does this work on mobile?', - answer: - 'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.', - }, -]; - -function FAQItemCard({ item }: { item: FAQItem }) { - const [open, setOpen] = useState(false); - - return ( -
- - {open && ( -
-

{item.answer}

-
- )} -
- ); -} - -export default function FAQPage() { - return ( -
-
-

- Frequently Asked Questions -

-

- Common questions about how Perfect Postcode works, where the data comes from, and how to use the - map. -

-
- {FAQ_ITEMS.map((item, index) => ( - - ))} -
-
-
- ); -} diff --git a/frontend/src/components/home/CategoryArt.tsx b/frontend/src/components/home/CategoryArt.tsx deleted file mode 100644 index ea5e46c..0000000 --- a/frontend/src/components/home/CategoryArt.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Decorative mini SVGs for homepage category cards. - * Purely visual — rendered at low opacity in the corner of each card. - */ -export default function CategoryArt({ - category, - className = '', -}: { - category: string; - className?: string; -}) { - const props = { className, width: 36, height: 36, viewBox: '0 0 36 36', fill: 'none' }; - - switch (category) { - case 'Property': - // Ascending bar chart - return ( - - - - - - ); - case 'Transport': - // Converging route lines - return ( - - - - - - - ); - case 'Crime': - // Shield outline - return ( - - - - - ); - case 'Education': - // Mortarboard / books - return ( - - - - - - ); - case 'Amenities': - // Scattered dots (map pins) - return ( - - - - - - - - - ); - case 'Demographics': - // Pie/donut segment - return ( - - - - - - ); - case 'Environment': - // Terrain wave lines - return ( - - - - - - ); - case 'Broadband': - // Signal waves (wifi) - return ( - - - - - - - ); - case 'Deprivation': - // Scale / balance - return ( - - - - - - - - ); - default: - return null; - } -} diff --git a/frontend/src/components/home/HexCanvas.tsx b/frontend/src/components/home/HexCanvas.tsx index e3b366e..cbed932 100644 --- a/frontend/src/components/home/HexCanvas.tsx +++ b/frontend/src/components/home/HexCanvas.tsx @@ -16,13 +16,13 @@ interface HexConfig { function generateHexes(): HexConfig[] { const hexes: HexConfig[] = []; for (let i = 0; i < HEX_COUNT; i++) { - const driftDuration = 18 + Math.random() * 35; + const driftDuration = 40 + Math.random() * 60; hexes.push({ size: 10 + Math.random() * 32, opacity: 0.06 + Math.random() * 0.18, top: Math.random() * 100, driftDuration, - bobDuration: 3 + Math.random() * 5, + bobDuration: 6 + Math.random() * 8, bobAmount: 8 + Math.random() * 30, delay: -Math.random() * driftDuration, reverse: Math.random() < 0.3, @@ -49,7 +49,7 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) { className="bg-teal-500" style={{ width: hex.size, - height: hex.size, + height: hex.size * 2 / Math.sqrt(3), opacity: hex.opacity * (isDark ? 0.6 : 1), clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)', animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`, diff --git a/frontend/src/components/home/HomeDemo.tsx b/frontend/src/components/home/HomeDemo.tsx deleted file mode 100644 index 29ea107..0000000 --- a/frontend/src/components/home/HomeDemo.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import MapComponent from '../map/Map'; -import { Slider } from '../ui/Slider'; -import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api'; -import { formatValue } from '../../lib/format'; -import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; -import { gradientToCss } from '../../lib/utils'; -import { TickerValue } from '../ui/TickerValue'; -import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; -import type { FeatureMeta, HexagonData } from '../../types'; - -const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 }; -const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km']; -const DEMO_BOUNDS = '49,-9.5,57,5'; -const DEMO_RESOLUTION = 5; - -const noop = () => {}; -const featureGradientStyle = gradientToCss(FEATURE_GRADIENT); - -interface HomeDemoProps { - features: FeatureMeta[]; - theme: 'light' | 'dark'; -} - -export default function HomeDemo({ features, theme }: HomeDemoProps) { - const [hexData, setHexData] = useState([]); - const [loading, setLoading] = useState(true); - const [fetching, setFetching] = useState(false); - const [sliderValues, setSliderValues] = useState>({}); - const [activeFeature, setActiveFeature] = useState(null); - const [dragValue, setDragValue] = useState<[number, number] | null>(null); - const [dragHexData, setDragHexData] = useState(null); - const fetchTimeoutRef = useRef>(); - const abortRef = useRef(); - const dragAbortRef = useRef(); - const activeFeatureRef = useRef(null); - activeFeatureRef.current = activeFeature; - - const demoFeatures = useMemo( - () => - DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter( - Boolean - ) as FeatureMeta[], - [features] - ); - - // Initialize slider values when features arrive - useEffect(() => { - if (demoFeatures.length === 0) return; - const initial: Record = {}; - for (const f of demoFeatures) { - if (f.min != null && f.max != null) { - initial[f.name] = [f.min, f.max]; - } - } - setSliderValues(initial); - }, [demoFeatures]); - - // Feature coloring only during drag; density (property count) otherwise - const viewFeatureName = activeFeature; - const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null; - const colorRange: [number, number] | null = - viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null; - const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null; - const displayData = dragHexData ?? hexData; - - // Fetch hexagons (debounced) — skipped while dragging - const fetchHexagons = useCallback(() => { - if (activeFeatureRef.current) return; - if (features.length === 0 || Object.keys(sliderValues).length === 0) return; - const params = new URLSearchParams({ - resolution: String(DEMO_RESOLUTION), - bounds: DEMO_BOUNDS, - }); - const filterParts: string[] = []; - for (const [name, [min, max]] of Object.entries(sliderValues)) { - const meta = features.find((f) => f.name === name); - if (meta?.min != null && meta?.max != null) { - if (min !== meta.min || max !== meta.max) { - filterParts.push(`${name}:${min}:${max}`); - } - } - } - if (filterParts.length > 0) { - params.set('filters', filterParts.join(',')); - } - abortRef.current?.abort(); - abortRef.current = new AbortController(); - setFetching(true); - fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal })) - .then((res) => { - assertOk(res, 'hexagons'); - return res.json(); - }) - .then((data: { features: HexagonData[] }) => { - setHexData(data.features); - setLoading(false); - setFetching(false); - }) - .catch((err) => { - logNonAbortError('Failed to fetch demo hexagons', err); - setFetching(false); - }); - }, [features, sliderValues]); - - useEffect(() => { - clearTimeout(fetchTimeoutRef.current); - fetchTimeoutRef.current = setTimeout(fetchHexagons, 200); - return () => clearTimeout(fetchTimeoutRef.current); - }, [fetchHexagons]); - - useEffect(() => { - return () => { - abortRef.current?.abort(); - dragAbortRef.current?.abort(); - clearTimeout(fetchTimeoutRef.current); - }; - }, []); - - // Drag start: fetch preview data with other filters only, fields=dragged feature - const handleDragStart = useCallback( - (name: string) => { - setActiveFeature(name); - const currentVal = sliderValues[name]; - const meta = features.find((f) => f.name === name); - setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null)); - - const params = new URLSearchParams({ - resolution: String(DEMO_RESOLUTION), - bounds: DEMO_BOUNDS, - }); - const otherFilterParts: string[] = []; - for (const [n, [min, max]] of Object.entries(sliderValues)) { - if (n === name) continue; - const m = features.find((f) => f.name === n); - if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) { - otherFilterParts.push(`${n}:${min}:${max}`); - } - } - if (otherFilterParts.length > 0) { - params.set('filters', otherFilterParts.join(',')); - } - params.set('fields', name); - - dragAbortRef.current?.abort(); - dragAbortRef.current = new AbortController(); - fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) - .then((res) => { - assertOk(res, 'hexagons'); - return res.json(); - }) - .then((data: { features: HexagonData[] }) => setDragHexData(data.features)) - .catch((err) => logNonAbortError('Failed to fetch demo drag data', err)); - }, - [features, sliderValues] - ); - - const handleSliderChange = useCallback( - (name: string, value: [number, number]) => { - setSliderValues((prev) => ({ ...prev, [name]: value })); - if (activeFeatureRef.current === name) { - setDragValue(value); - } - }, - [] - ); - - const handleDragEnd = useCallback(() => { - setActiveFeature(null); - setDragValue(null); - setDragHexData(null); - }, []); - - return ( -
- {/* Map */} -
-
-
- -
- {loading && ( -
-
- -

- Connecting to server... -

-
-
- )} - {!loading && fetching && ( -
- Loading... -
- )} - {/* Colour spectrum legend */} -
-
-
- {activeFeature ? viewMeta?.name || activeFeature : 'Property density'} -
-
- {colorRange && ( -
- - -
- )} -
-
-
- - {/* Sliders */} -
- {demoFeatures.map((feature) => { - const value = sliderValues[feature.name]; - if (!value || feature.min == null || feature.max == null) return null; - const isActive = activeFeature === feature.name; - return ( -
-
- - {feature.name} - - - {formatValue(value[0], feature)} – {formatValue(value[1], feature)} - -
- handleSliderChange(feature.name, [min, max])} - onPointerDown={() => handleDragStart(feature.name)} - onPointerUp={() => handleDragEnd()} - /> -
- ); - })} -
-
- ); -} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 73bd963..1b18ca0 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -4,6 +4,7 @@ import HexCanvas from './HexCanvas'; import ScrollStory from './ScrollStory'; import BottomIllustration from './BottomIllustration'; import { TickerValue } from '../ui/TickerValue'; +import { ChevronIcon } from '../ui/icons/ChevronIcon'; import type { FeatureMeta } from '../../types'; export default function HomePage({ @@ -11,11 +12,13 @@ export default function HomePage({ onOpenPricing, theme = 'light', features = [], + hidePricing, }: { onOpenDashboard: () => void; onOpenPricing: () => void; theme?: 'light' | 'dark'; features?: FeatureMeta[]; + hidePricing?: boolean; }) { const [statsActive, setStatsActive] = useState(false); useEffect(() => { @@ -31,52 +34,68 @@ export default function HomePage({
{/* Hero */} -
+
-
-
-

- Get more home for your money. -

-

- Buying a home may be your most important decision. Why not ensure you make your - best-ever decision? -

-

- You have so many options. Picking the best one is daunting and stressful. It - won't be anymore when looking at the property landscape through our dashboard. -

-
- - +
+
+
+
+

+ Get more home for your money. +

+

+ Buying a home may be your most important decision. Why not ensure you make your + best-ever decision? +

+

+ You have so many options. Picking the best one is daunting and stressful. It + won't be anymore when looking at the property landscape through our dashboard. +

+
+ + {hidePricing ? ( + + You have lifetime access! + + ) : ( + + )} +
+
+
+
+ +
+
properties
+
+
+
+ +
+
data layers
+
+
+
Every
+
postcode in England
+
+
-
-
-
- -
-
properties
-
-
-
- -
-
data layers
-
-
-
Every
-
postcode in England
-
+
+
+

+ Let's look at an example +

+
diff --git a/frontend/src/components/home/ScrollStory.tsx b/frontend/src/components/home/ScrollStory.tsx index b97d18f..72afb73 100644 --- a/frontend/src/components/home/ScrollStory.tsx +++ b/frontend/src/components/home/ScrollStory.tsx @@ -1,19 +1,23 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import MapComponent from '../map/Map'; import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api'; import { formatValue } from '../../lib/format'; +import { zoomToResolution } from '../../lib/map-utils'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { FeatureMeta, HexagonData } from '../../types'; -const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 }; +const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 }; +const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 }; + +function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3); +} const DEMO_FEATURE_NAMES = [ 'Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km', ]; -const DEMO_BOUNDS = '49,-9.5,57,5'; -const DEMO_RESOLUTION = 5; -const noop = () => {}; +const noop = () => { }; // Filter fractions per stage: featureName -> [minFrac, maxFrac] // 0 = feature.min, 1 = feature.max @@ -148,6 +152,9 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { const stepRefs = useRef<(HTMLDivElement | null)[]>([]); const abortRef = useRef(); const fetchTimeoutRef = useRef>(); + const sectionRef = useRef(null); + const [scrollProgress, setScrollProgress] = useState(0); + const rafRef = useRef(0); const demoFeatures = useMemo( () => @@ -188,12 +195,82 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { return () => observers.forEach((o) => o.disconnect()); }, [demoFeatures.length]); - // Fetch hex data when filters change + // Track scroll progress through the section for zoom interpolation + useEffect(() => { + const section = sectionRef.current; + if (!section) return; + let scrollParent: HTMLElement | null = section.parentElement; + while (scrollParent) { + const { overflow, overflowY } = getComputedStyle(scrollParent); + if (['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowY)) break; + scrollParent = scrollParent.parentElement; + } + if (!scrollParent) return; + + const handleScroll = () => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + const rect = section.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const totalTravel = rect.height - viewportHeight; + if (totalTravel <= 0) return; + const scrolled = -rect.top; + const progress = Math.max(0, Math.min(1, scrolled / totalTravel)); + setScrollProgress(progress); + }); + }; + + scrollParent.addEventListener('scroll', handleScroll, { passive: true }); + handleScroll(); + return () => { + scrollParent.removeEventListener('scroll', handleScroll); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + const demoView = useMemo(() => { + const t = easeOutCubic(scrollProgress); + return { + longitude: DEMO_VIEW_START.longitude + (DEMO_VIEW_END.longitude - DEMO_VIEW_START.longitude) * t, + latitude: DEMO_VIEW_START.latitude + (DEMO_VIEW_END.latitude - DEMO_VIEW_START.latitude) * t, + zoom: DEMO_VIEW_START.zoom + (DEMO_VIEW_END.zoom - DEMO_VIEW_START.zoom) * t, + pitch: 0, + }; + }, [scrollProgress]); + + // Derive H3 resolution from current zoom (discrete — only changes at thresholds) + const resolution = zoomToResolution(demoView.zoom); + + // Compute bounds string from current view, rounded to 0.5° to avoid refetching on every scroll tick + const demoBounds = useMemo(() => { + const { longitude, latitude, zoom } = demoView; + const scale = Math.pow(2, zoom); + const degreesPerPixelLng = 360 / (512 * scale); + const halfW = (1200 / 2) * degreesPerPixelLng * 1.3; + const latRad = (latitude * Math.PI) / 180; + const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2; + const worldSize = 512 * scale; + const halfH = (800 / 2) * 1.3; + const topY = mercY * worldSize - halfH; + const botY = mercY * worldSize + halfH; + const toLat = (py: number) => { + const my = Math.max(0.001, Math.min(0.999, py / worldSize)); + return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI; + }; + const snap = (v: number) => Math.round(v * 2) / 2; + const south = snap(Math.max(-85, toLat(botY))); + const west = snap(Math.max(-180, longitude - halfW)); + const north = snap(Math.min(85, toLat(topY))); + const east = snap(Math.min(180, longitude + halfW)); + return `${south},${west},${north},${east}`; + }, [demoView]); + + // Fetch hex data when resolution, filters, or bounds change useEffect(() => { if (features.length === 0) return; const params = new URLSearchParams({ - resolution: String(DEMO_RESOLUTION), - bounds: DEMO_BOUNDS, + resolution: String(resolution), + bounds: demoBounds, }); const filterParts: string[] = []; for (const [name, [min, max]] of Object.entries(stageFilters)) { @@ -219,7 +296,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { .catch((err) => logNonAbortError('Failed to fetch story hexagons', err)); }, 300); return () => clearTimeout(fetchTimeoutRef.current); - }, [features, stageFilters, stage]); + }, [features, stageFilters, stage, resolution, demoBounds]); useEffect(() => { return () => { @@ -234,13 +311,16 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { const colorRange: [number, number] | null = viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null; + // Defer hex data so scroll zoom stays smooth while layer rebuilds happen in the background + const deferredHexData = useDeferredValue(hexData); + return ( -
+
{/* Sticky map background */}
)} - {/* Filter indicators */} -
-
+ {/* Filter indicators — left sidebar */} +
+
+
+ Filters +
{demoFeatures.map((feature) => { const filterVal = stageFilters[feature.name]; const isActive = !!filterVal; @@ -288,20 +371,20 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) { key={feature.name} className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`} > -
+
{feature.name} {isActive && filterVal && ( - + {formatValue(filterVal[0], feature)}– {formatValue(filterVal[1], feature)} )}
-
+

{isAdminInvite - ? 'You have been invited to get a free lifetime license.' - : 'A friend has shared a 30% discount on the lifetime license.'} + ? 'You have been invited to get free lifetime access.' + : 'A friend has shared a 30% discount on lifetime access.'}

{isAdminInvite && (
Free - lifetime license + lifetime access
)} {!isAdminInvite && pricePence !== null && pricePence > 0 && ( @@ -168,7 +168,7 @@ export default function InvitePage({ {`\u00A3${pricePence / 100}`} - {`\u00A3${Math.round(pricePence * 0.7) / 100}`} + {`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`} /once
diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index 609d314..6fc0917 100644 --- a/frontend/src/components/learn/LearnPage.tsx +++ b/frontend/src/components/learn/LearnPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef } from 'react'; import { ChevronIcon } from '../ui/icons/ChevronIcon'; -type LearnTab = 'data-sources' | 'faq'; +type LearnTab = 'data-sources' | 'faq' | 'support'; const DATA_SOURCES = [ { @@ -206,7 +206,27 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'How often is the data updated?', answer: - 'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source.', + 'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source. All updates are included with your access at no extra cost.', + }, + { + question: 'What data is included?', + answer: + 'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.', + }, + { + question: 'What can I access on the free tier?', + answer: + 'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need lifetime access.', + }, + { + question: 'What does "lifetime" mean?', + answer: + 'Your access never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.', + }, + { + question: 'Can I get a refund?', + answer: + 'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.', }, ]; @@ -214,7 +234,7 @@ function FAQItemCard({ item }: { item: FAQItem }) { const [open, setOpen] = useState(false); return ( -
+
+
- {/* Content */}
{tab === 'data-sources' ? ( <> @@ -308,10 +331,10 @@ export default function LearnPage() { ref={(el) => { cardRefs.current[source.id] = el; }} - className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${ + className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${ highlightedId === source.id ? 'border-teal-400 ring-2 ring-teal-400' - : 'border-warm-200 dark:border-navy-700' + : 'border-warm-200 dark:border-warm-700' }`} >
@@ -400,7 +423,7 @@ export default function LearnPage() {
- ) : ( + ) : tab === 'faq' ? (

Frequently Asked Questions @@ -415,6 +438,27 @@ export default function LearnPage() { ))}

+ ) : ( +
+

+ Support +

+

+ Have a question? Check our FAQ or reach out to us directly. +

+
+

Need help? Email us at

+ + support@propertymap.co.uk + +

+ We typically respond within 24 hours. +

+
+
)}
diff --git a/frontend/src/components/map/AISummaryCard.tsx b/frontend/src/components/map/AISummaryCard.tsx deleted file mode 100644 index 4248142..0000000 --- a/frontend/src/components/map/AISummaryCard.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { ChevronIcon } from '../ui/icons'; -import { LightbulbIcon } from '../ui/icons/LightbulbIcon'; - -interface AISummaryCardProps { - summary?: string; - loading?: boolean; - error?: string | null; - expanded: boolean; - onToggleExpanded: () => void; -} - -export default function AISummaryCard({ - summary, - loading, - error, - expanded, - onToggleExpanded, -}: AISummaryCardProps) { - if (!summary && !loading && !error) return null; - - return ( -
-
- - {expanded && ( - <> - {error ? ( -
- Failed to generate summary. -
- ) : loading ? ( -
-
-
-
- ) : ( -

{summary}

- )} - - )} -
-
- ); -} diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 62e8166..ba90d25 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -22,7 +22,6 @@ import { IconButton } from '../ui/IconButton'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; -import AISummaryCard from './AISummaryCard'; import StreetViewEmbed from './StreetViewEmbed'; import HistogramLegend from './HistogramLegend'; @@ -38,9 +37,6 @@ interface AreaPaneProps { hexagonLocation: HexagonLocation | null; filters: FeatureFilters; onNavigateToSource?: (slug: string, featureName: string) => void; - aiSummary?: string; - aiSummaryLoading?: boolean; - aiSummaryError?: string | null; } export default function AreaPane({ @@ -55,16 +51,11 @@ export default function AreaPane({ hexagonLocation, filters, onNavigateToSource, - aiSummary, - aiSummaryLoading, - aiSummaryError, }: AreaPaneProps) { - // For postcodes, use local data for count const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count; const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); const [infoFeature, setInfoFeature] = useState(null); const [collapsedGroups, toggleGroup] = useCollapsibleGroups(); - const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -94,7 +85,7 @@ export default function AreaPane({ return (
-
+
@@ -133,13 +124,6 @@ export default function AreaPane({ )}
- setAiSummaryExpanded(!aiSummaryExpanded)} - /> {loading && !stats ? ( ) : stats ? ( @@ -154,7 +138,6 @@ export default function AreaPane({ const stackedCharts = STACKED_GROUPS[group.name]; const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name]; - // Features that are part of a stacked enum config (rendered as compact charts) const stackedEnumFeatureNames = new Set( stackedEnumCharts?.flatMap((c) => [c.feature, ...c.components].filter((s): s is string => Boolean(s)) @@ -173,11 +156,9 @@ export default function AreaPane({ /> {isExpanded && (
- {/* Price History in Property group */} {group.name === 'Property' && stats.price_history && (() => { - // Only show chart if there are at least 2 unique years const uniqueYears = new Set( stats.price_history.map((p) => Math.floor(p.year)) ); @@ -191,8 +172,7 @@ export default function AreaPane({
)} {stackedCharts - ? // Render stacked charts for this group - stackedCharts.map((chart) => { + ? stackedCharts.map((chart) => { const segments = chart.components .map((name) => ({ name, @@ -200,7 +180,6 @@ export default function AreaPane({ })) .filter((s) => s.value > 0); - // Use aggregate feature stats if available, otherwise sum components const aggregateStats = chart.feature ? numericByName.get(chart.feature) : undefined; @@ -240,8 +219,7 @@ export default function AreaPane({
); }) - : // Default: render each feature individually (skip stacked enum features) - group.features + : group.features .filter((f) => !stackedEnumFeatureNames.has(f.name)) .map((feature) => { const numericStats = numericByName.get(feature.name); @@ -306,13 +284,11 @@ export default function AreaPane({ return null; })} - {/* Stacked enum charts */} {stackedEnumCharts?.map((chart) => { const featureMeta = chart.feature ? globalFeatureByName.get(chart.feature) : undefined; - // Single component: render as a stacked bar (like crime charts) if (chart.components.length === 1) { const stats = enumByName.get(chart.components[0]); if (!stats) return null; @@ -355,7 +331,6 @@ export default function AreaPane({ ); } - // Multi-component: render as compact multi-row chart (like risk features) const components = chart.components .map((name) => { const stats = enumByName.get(name); diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx index c1813fe..6f494a3 100644 --- a/frontend/src/components/map/DualHistogram.tsx +++ b/frontend/src/components/map/DualHistogram.tsx @@ -131,7 +131,7 @@ export function DualHistogram({ ); } -export function SkeletonHistogram() { +function SkeletonHistogram() { return (
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index a08bf62..8d2ca62 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -9,9 +9,9 @@ import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; -import { RouteIcon, PlusIcon } from '../ui/icons'; +import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons'; import { IconButton } from '../ui/IconButton'; -import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; interface FeatureBrowserProps { availableFeatures: FeatureMeta[]; @@ -71,26 +71,58 @@ export default function FeatureBrowser({
- {showTravelModes && TRANSPORT_MODES.map((mode) => ( -
-
-
onAddTravelTimeEntry(mode)}> - -
- - Travel Time ({MODE_LABELS[mode]}) - - - Filter by journey time to a destination - + {showTravelModes && ( +
+ toggleGroup('Travel Time')} + className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + > + + {TRANSPORT_MODES.length} + + + {(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; + return ( +
+
onAddTravelTimeEntry(mode)}> + +
+ + {MODE_LABELS[mode]} + + + Filter by journey time to a destination + +
+
+
+ {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"> + + +
-
- onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}> - - -
+ ); + })}
- ))} + )} {grouped.map((group) => { const isExpanded = isSearching || expandedGroups.has(group.name); return ( diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index c8b1d5e..57c06db 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -20,8 +20,20 @@ import { TravelTimeCard } from './TravelTimeCard'; import { type TransportMode, type TravelTimeEntry, + travelFieldKey, } from '../../hooks/useTravelTime'; +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, @@ -33,7 +45,6 @@ function SliderLabels({ max: number; value: [number, number]; displayValues?: [number, number]; - /** When true and slider is at max, append "+" to indicate unrestricted upper bound */ absoluteMax?: boolean; }) { const range = max - min || 1; @@ -72,7 +83,6 @@ interface FiltersProps { onDragEnd: () => void; pinnedFeature: string | null; onTogglePin: (name: string) => void; - onCancelPin: () => void; onNavigateToSource?: (slug: string, featureName: string) => void; openInfoFeature?: string | null; onClearOpenInfoFeature?: () => void; @@ -102,7 +112,6 @@ export default memo(function Filters({ onDragEnd, pinnedFeature, onTogglePin, - onCancelPin: _onCancelPin, onNavigateToSource, openInfoFeature, onClearOpenInfoFeature, @@ -117,37 +126,43 @@ export default memo(function Filters({ aiFilterNotes, onAiFilterSubmit, }: FiltersProps) { - const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); - const enabledFeatureList = features.filter( - (f) => enabledFeatures.has(f.name) && f.name !== 'Listing status' - ); - - const listingToggles = useMemo(() => { + const activeListingType = useMemo((): ListingType => { const val = filters['Listing status'] as string[] | undefined; - if (!val) return { historical: true, buy: true, rent: true }; - return { - historical: val.includes('Historical sale'), - buy: val.includes('For sale'), - rent: val.includes('For rent'), - }; + if (!val || val.length === 0) return 'historical'; + if (val.includes('For sale')) return 'buy'; + if (val.includes('For rent')) return 'rent'; + return 'historical'; }, [filters]); - const handleListingToggle = useCallback( - (key: 'historical' | 'buy' | 'rent') => { - const next = { ...listingToggles, [key]: !listingToggles[key] }; - const allOn = next.historical && next.buy && next.rent; - const allOff = !next.historical && !next.buy && !next.rent; - if (allOn || allOff) { - onRemoveFilter('Listing status'); + const availableFeatures = useMemo( + () => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)), + [features, enabledFeatures, activeListingType] + ); + const enabledFeatureList = useMemo( + () => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'), + [features, enabledFeatures] + ); + + const handleListingSelect = useCallback( + (type: ListingType) => { + if (type === activeListingType && !filters['Listing status']) return; + for (const name of Object.keys(filters)) { + if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { + onRemoveFilter(name); + } + } + if (type === 'historical' && !filters['Listing status']) { + onFilterChange('Listing status', ['Historical sale']); return; } - const values: string[] = []; - if (next.historical) values.push('Historical sale'); - if (next.buy) values.push('For sale'); - if (next.rent) values.push('For rent'); - onFilterChange('Listing status', values); + const valueMap: Record = { + historical: 'Historical sale', + buy: 'For sale', + rent: 'For rent', + }; + onFilterChange('Listing status', [valueMap[type]]); }, - [listingToggles, onFilterChange, onRemoveFilter] + [activeListingType, filters, onFilterChange, onRemoveFilter] ); const containerRef = useRef(null); @@ -181,8 +196,7 @@ export default memo(function Filters({ return scales; }, [features]); - const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent; - const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0); + const badgeCount = enabledFeatureList.length + activeEntryCount; return (
@@ -198,16 +212,26 @@ export default memo(function Filters({
-
- Show - - handleListingToggle('historical')} size="xs" /> - handleListingToggle('buy')} size="xs" /> - handleListingToggle('rent')} size="xs" /> - +
+
+ {(['historical', 'buy', 'rent'] as const).map((type) => { + const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' }; + const isActive = activeListingType === type; + return ( + + ); + })} +
@@ -224,20 +248,39 @@ export default memo(function Filters({
- {travelTimeEntries.map((entry, index) => ( -
- onTravelTimeSetDestination(index, slug, label)} - onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} - onRemove={() => onTravelTimeRemoveEntry(index)} - /> -
- ))} + {travelTimeEntries.length > 0 && ( +
+ toggleGroup('Travel Time')} + className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + > + + {travelTimeEntries.length} + + + {!collapsedGroups.has('Travel Time') && ( +
+ {travelTimeEntries.map((entry, index) => ( + onTogglePin(travelFieldKey(entry))} + onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} + onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)} + onRemove={() => onTravelTimeRemoveEntry(index)} + /> + ))} +
+ )} +
+ )} {enabledFeatureList.length === 0 && activeEntryCount === 0 && (

diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index 97a248a..024bd22 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -29,9 +29,11 @@ const ZOOM_FOR_TYPE: Record = { export default function LocationSearch({ onFlyTo, onLocationSearched, + onMouseEnter, }: { onFlyTo: (lat: number, lng: number, zoom: number) => void; onLocationSearched?: (postcode: SearchedLocation | null) => void; + onMouseEnter?: () => void; }) { const search = useLocationSearch(); const [error, setError] = useState(null); @@ -118,8 +120,8 @@ export default function LocationSearch({ } return ( -

-
+
+
void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void; initialViewState?: ViewState; + flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>; theme?: 'light' | 'dark'; screenshotMode?: boolean; ogMode?: boolean; @@ -49,11 +49,9 @@ interface MapProps { bounds?: Bounds | null; hideLegend?: boolean; travelTimeEntries?: TravelTimeEntry[]; - travelTimeColorRanges?: Map; } const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; -const EMPTY_TRAVEL_RANGES = new globalThis.Map(); interface Dimensions { width: number; @@ -97,6 +95,7 @@ export default memo(function Map({ onHexagonClick, onHexagonHover, initialViewState, + flyToRef, theme = 'light', screenshotMode = false, ogMode = false, @@ -106,12 +105,17 @@ export default memo(function Map({ bounds: viewportBounds, hideLegend = false, travelTimeEntries = EMPTY_TRAVEL_ENTRIES, - travelTimeColorRanges = EMPTY_TRAVEL_RANGES, }: MapProps) { const containerRef = useRef(null); - const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW_STATE); + const [internalViewState, setInternalViewState] = useState( + initialViewState || INITIAL_VIEW_STATE + ); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + // In screenshot mode, use the prop directly for instant updates (no async lag) + const viewState = + screenshotMode && initialViewState ? initialViewState : internalViewState; + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -130,8 +134,6 @@ export default memo(function Map({ useEffect(() => { if (dimensions.width === 0 || dimensions.height === 0) return; - // Send exact viewport bounds - server will filter to only return - // hexagons/postcodes that intersect this precise AABB const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); const resolution = zoomToResolution(viewState.zoom); @@ -145,19 +147,14 @@ export default memo(function Map({ }, [viewState, dimensions, onViewChange]); const handleMove = useCallback((evt: { viewState: ViewState }) => { - setViewState(evt.viewState); + setInternalViewState(evt.viewState); }, []); const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { - setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); + setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); }, []); - const handleMapLoad = useCallback( - (_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { - // Road opacity is set in getMapStyle - }, - [] - ); + if (flyToRef) flyToRef.current = handleFlyTo; const mapStyle = useMemo(() => getMapStyle(theme), [theme]); @@ -169,7 +166,6 @@ export default memo(function Map({ postcodeCountRange, colorFeatureMeta, handleMouseLeave, - primaryTravelIndex, } = useDeckLayers({ data, postcodeData, @@ -187,7 +183,6 @@ export default memo(function Map({ selectedPostcodeGeometry, bounds: viewportBounds, travelTimeEntries, - travelTimeColorRanges, }); return ( @@ -195,7 +190,7 @@ export default memo(function Map({ - + {!hideLegend && - (primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? ( - - ) : viewFeature && colorRange && colorFeatureMeta ? ( - + (viewFeature && colorRange ? ( + viewFeature.startsWith('tt_') ? ( + + ) : colorFeatureMeta ? ( + + ) : null ) : ( { @@ -129,13 +125,34 @@ export default function MapPage({ [aiFilters.fetchAiFilters, handleSetFilters] ); - // Travel time hook const travelTime = useTravelTime(initialTravelTime); - // License hook + const handleTravelTimeSetDestination = useCallback( + (index: number, slug: string, label: string) => { + travelTime.handleSetDestination(index, slug, label); + const entry = travelTime.entries[index]; + if (entry) { + handleSetPin(`tt_${entry.mode}_${slug}`); + } + }, + [travelTime.handleSetDestination, travelTime.entries, handleSetPin] + ); + + const handleTravelTimeRemoveEntry = useCallback( + (index: number) => { + const entry = travelTime.entries[index]; + if (entry?.slug && pinnedFeature === travelFieldKey(entry)) { + handleCancelPin(); + } + travelTime.handleRemoveEntry(index); + }, + [travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin] + ); + const license = useLicense(); - // Map data hook + const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null); + const mapData = useMapData({ filters, features, @@ -146,19 +163,16 @@ export default function MapPage({ travelTimeEntries: travelTime.entries, }); - // Keep filter bounds in sync with map data useEffect(() => { updateBoundsInfo(mapData.bounds, mapData.resolution); }, [mapData.bounds, mapData.resolution, updateBoundsInfo]); - // Hexagon selection hook const selection = useHexagonSelection({ filters, features, resolution: mapData.resolution, }); - // Location search handler — selects postcode + shows stats const handleLocationSearchResult = useCallback( (result: SearchedLocation | null) => { if (result) { @@ -171,10 +185,16 @@ export default function MapPage({ [selection.handleLocationSearch, selection.handleCloseSelection, isMobile] ); - // POI data + const handleZoomToFreeZone = useCallback(() => { + mapFlyToRef.current?.( + INITIAL_VIEW_STATE.latitude, + INITIAL_VIEW_STATE.longitude, + INITIAL_VIEW_STATE.zoom + ); + }, []); + const pois = usePOIData(mapData.bounds, selectedPOICategories); - // Compute data range for travel time slider per entry index (full min/max for slider bounds) const travelTimeDataRanges = useMemo((): globalThis.Map => { const ranges = new globalThis.Map(); for (let i = 0; i < travelTime.entries.length; i++) { @@ -193,16 +213,13 @@ export default function MapPage({ return ranges; }, [travelTime.entries, mapData.data]); - // Sync current state to URL useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); - // Set initial view and tab from URL state useEffect(() => { mapData.setInitialView(initialViewState); selection.setRightPaneTab(initialTab); }, []); // eslint-disable-line react-hooks/exhaustive-deps - // On mobile, open drawer and switch tab when hexagon is clicked const { handleHexagonClick } = selection; const handleMobileHexagonClick = useCallback( (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => { @@ -214,7 +231,6 @@ export default function MapPage({ [handleHexagonClick] ); - // Compute hexagon location for external links const hexagonLocation = useMemo(() => { const hexId = selection.selectedHexagon?.id; const isPostcode = selection.selectedHexagon?.type === 'postcode'; @@ -239,19 +255,8 @@ export default function MapPage({ mapData.resolution, ]); - // Tutorial const tutorial = useTutorial(initialLoading, isMobile); - // AI area summary - const aiSummary = useAreaSummary({ - stats: selection.areaStats, - hexagonId: selection.selectedHexagon?.id || null, - isPostcode: selection.selectedHexagon?.type === 'postcode', - filters, - features, - }); - - // Export to Excel const [exporting, setExporting] = useState(false); const handleExport = useCallback(() => { if (!mapData.bounds || exporting) return; @@ -280,12 +285,10 @@ export default function MapPage({ .finally(() => setExporting(false)); }, [mapData.bounds, filters, features, exporting]); - // Report export state to parent (Header) useEffect(() => { onExportStateChange?.({ onExport: handleExport, exporting }); }, [handleExport, exporting, onExportStateChange]); - // Mobile legend data (computed from API-fetched data, which is already viewport-scoped) const mobileLegendMeta = useMemo( () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), [viewFeature, features] @@ -305,7 +308,6 @@ export default function MapPage({ return [min, max]; }, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]); - // Signal screenshot readiness once map data has loaded useEffect(() => { if (screenshotMode && !mapData.loading && mapData.data.length > 0) { window.__screenshot_ready = true; @@ -337,13 +339,11 @@ export default function MapPage({ ogMode={ogMode} bounds={mapData.bounds} travelTimeEntries={travelTime.entries} - travelTimeColorRanges={mapData.travelTimeColorRanges} />
); } - // Shared pane content renderers const renderAreaPane = () => ( ); @@ -375,7 +372,6 @@ export default function MapPage({ loading={selection.loadingProperties} hexagonId={selection.selectedHexagon?.id || null} onLoadMore={selection.handleLoadMoreProperties} - onClose={selection.handleCloseSelection} /> ); @@ -403,14 +399,13 @@ export default function MapPage({ onDragEnd={handleDragEnd} pinnedFeature={pinnedFeature} onTogglePin={handleTogglePin} - onCancelPin={handleCancelPin} openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} travelTimeEntries={travelTime.entries} travelTimeDataRanges={travelTimeDataRanges} onTravelTimeAddEntry={travelTime.handleAddEntry} - onTravelTimeRemoveEntry={travelTime.handleRemoveEntry} - onTravelTimeSetDestination={travelTime.handleSetDestination} + onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} + onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={travelTime.handleTimeRangeChange} aiFilterLoading={aiFilters.loading} aiFilterError={aiFilters.error} @@ -419,7 +414,6 @@ export default function MapPage({ /> ); - // Mobile layout if (isMobile) { return (
@@ -434,7 +428,6 @@ export default function MapPage({
)} - {/* Map — 45% */}
{mapData.loading && ( -
- Loading... +
+
+ + Loading... +
)} - {/* Floating POI button */} - {/* Floating POI panel */} {poiPaneOpen && (
{renderPOIPane()} @@ -482,67 +476,54 @@ export default function MapPage({ )}
- {/* Bottom panel — 55% */}
- {/* Legend */} - {(() => { - const primaryIdx = travelTime.entries.findIndex( - (e, i) => e.slug && mapData.travelTimeColorRanges.get(i) - ); - if (primaryIdx >= 0) { - return ( - - ); - } - if (viewFeature && mapData.colorRange && mobileLegendMeta) { - return ( - - ); - } - return ( + {viewFeature && mapData.colorRange ? ( + viewFeature.startsWith('tt_') ? ( + ) : mobileLegendMeta ? ( + - ); - })()} - {/* Filters content */} + ) : null + ) : ( + + )}
{renderFilters()}
- {/* Mobile drawer for full-screen hexagon details */} {mobileDrawerOpen && selection.selectedHexagon && ( setMobileDrawerOpen(false)} @@ -557,14 +538,13 @@ export default function MapPage({ onLoginClick={onLoginClick ?? (() => {})} onRegisterClick={onRegisterClick ?? (() => {})} onStartCheckout={() => license.startCheckout()} - onDismiss={() => {}} + onZoomToFreeZone={handleZoomToFreeZone} /> )}
); } - // Desktop layout (unchanged) return (
{initialLoading && ( @@ -589,7 +569,6 @@ export default function MapPage({ disableScrolling /> - {/* Left Pane */}
- {/* Map */}
{mapData.loading && ( -
- Loading... +
+
+ + Loading... +
)} {/* Floating POI button */} @@ -652,7 +633,6 @@ export default function MapPage({ )}
- {/* Right Pane */}
{})} onRegisterClick={onRegisterClick ?? (() => {})} onStartCheckout={() => license.startCheckout()} - onDismiss={() => {}} + onZoomToFreeZone={handleZoomToFreeZone} /> )}
diff --git a/frontend/src/components/map/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx index 2452c49..95fa63e 100644 --- a/frontend/src/components/map/PriceHistoryChart.tsx +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -49,15 +49,30 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { if (arr) arr.push(p.price); else byYear.set(yr, [p.price]); } - const meds = Array.from(byYear.entries()) + const yearlyMedians = Array.from(byYear.entries()) .map(([yr, prices]) => { prices.sort((a, b) => a - b); const mid = Math.floor(prices.length / 2); const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2; - return { year: yr + 0.5, price: median }; + return { year: yr, price: median }; }) .sort((a, b) => a.year - b.year); + // 3-year rolling average + const meds = yearlyMedians.map((pt, i) => { + let sum = pt.price; + let count = 1; + for (let j = i - 1; j >= 0 && pt.year - yearlyMedians[j].year <= 1; j--) { + sum += yearlyMedians[j].price; + count++; + } + for (let j = i + 1; j < yearlyMedians.length && yearlyMedians[j].year - pt.year <= 1; j++) { + sum += yearlyMedians[j].price; + count++; + } + return { year: pt.year + 0.5, price: sum / count }; + }); + const ticks = niceTicksForRange(pMin, pMax, 4); return { diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index bfc2461..ee25c8e 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Property } from '../../types'; import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format'; import { getNum } from '../../lib/property-fields'; @@ -13,7 +13,6 @@ interface PropertiesPaneProps { loading: boolean; hexagonId: string | null; onLoadMore: () => void; - onClose: () => void; onNavigateToSource?: (slug: string) => void; } @@ -23,7 +22,6 @@ export function PropertiesPane({ loading, hexagonId, onLoadMore, - onClose: _onClose, onNavigateToSource, }: PropertiesPaneProps) { const [search, setSearch] = useState(''); @@ -123,13 +121,9 @@ function PropertyLoadingSkeleton() {
{Array.from({ length: 5 }).map((_, idx) => (
- {/* Address */}
- {/* Postcode */}
- {/* Price */}
- {/* Property details grid */}
{Array.from({ length: 6 }).map((_, i) => (
@@ -141,39 +135,93 @@ function PropertyLoadingSkeleton() { ); } +const LISTING_STATUS_STYLES: Record = { + 'For sale': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300', + 'For rent': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + 'Historical sale': 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300', +}; + +function ListingStatusBadge({ status }: { status: string }) { + const style = LISTING_STATUS_STYLES[status] ?? LISTING_STATUS_STYLES['Historical sale']; + return {status}; +} + function PropertyCard({ property }: { property: Property }) { const price = getNum(property, 'Last known price'); const estimatedPrice = getNum(property, 'Estimated current price'); const pricePerSqm = getNum(property, 'Price per sqm'); const estPricePerSqm = getNum(property, 'Est. price per sqm'); const floorArea = getNum(property, 'Total floor area (sqm)'); - const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)'); - const age = getNum(property, 'Approximate construction age'); + const rooms = getNum(property, 'Number of bedrooms & living rooms'); + const age = getNum(property, 'Construction age'); const transactionDate = getNum(property, 'Date of last transaction'); - const councilTax = getNum(property, 'Council tax (£/yr)'); - const councilTaxD = getNum(property, 'Council tax Band D (£/yr)'); + const askingPrice = getNum(property, 'Asking price'); + const askingRent = getNum(property, 'Asking rent (monthly)'); + const bedrooms = getNum(property, 'Bedrooms'); + const bathrooms = getNum(property, 'Bathrooms'); + const listingDate = getNum(property, 'Listing date'); + + const listingStatus = property.listing_status; return ( -
-
- {property.address || 'Unknown Address'} +
+
+
+
+ {property.address || 'Unknown Address'} +
+
{property.postcode}
+
+ {listingStatus && }
-
{property.postcode}
- {price !== undefined && ( + {property.property_sub_type && ( +
+ {property.property_sub_type} +
+ )} + + {askingPrice !== undefined && (
- £{formatNumber(price)} - {transactionDate !== undefined && ( - - {' '} - ({formatTransactionDate(transactionDate)}) + {property.price_qualifier && ( + + {property.price_qualifier}{' '} )} - {pricePerSqm !== undefined && ( + £{formatNumber(askingPrice)} +
+ )} + + {askingRent !== undefined && ( +
+ £{formatNumber(askingRent)} + /mo +
+ )} + + {price !== undefined && ( +
+ {askingPrice !== undefined || askingRent !== undefined ? ( - {' '} - £{formatNumber(pricePerSqm)}/m² + Last sold: £{formatNumber(price)} + {transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`} + ) : ( + <> + £{formatNumber(price)} + {transactionDate !== undefined && ( + + {' '} + ({formatTransactionDate(transactionDate)}) + + )} + {pricePerSqm !== undefined && ( + + {' '} + £{formatNumber(pricePerSqm)}/m² + + )} + )}
)} @@ -213,6 +261,18 @@ function PropertyCard({ property }: { property: Property }) { {formatNumber(floorArea)}m²
)} + {bedrooms !== undefined && ( +
+ Bedrooms:{' '} + {formatNumber(bedrooms)} +
+ )} + {bathrooms !== undefined && ( +
+ Bathrooms:{' '} + {formatNumber(bathrooms)} +
+ )} {rooms !== undefined && (
Rooms: {formatNumber(rooms)} @@ -236,19 +296,30 @@ function PropertyCard({ property }: { property: Property }) { {property.potential_energy_rating}
)} - {councilTax !== undefined ? ( + {listingDate !== undefined && (
- Council tax: £ - {formatNumber(councilTax)}/yr + Listed:{' '} + {formatTransactionDate(listingDate)}
- ) : councilTaxD !== undefined ? ( -
- Council tax (D): £ - {formatNumber(councilTaxD)}/yr -
- ) : null} + )}
+ {property.listing_features && property.listing_features.length > 0 && ( +
+
Key features
+
+ {property.listing_features.map((feature, idx) => ( + + {feature} + + ))} +
+
+ )} + {property.renovation_history && property.renovation_history.length > 0 && (
Renovations
@@ -265,6 +336,19 @@ function PropertyCard({ property }: { property: Property }) {
)} + + {property.listing_url && ( + + )}
); } diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 3ff521d..424dd98 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -3,6 +3,7 @@ import { Slider } from '../ui/Slider'; import { IconButton } from '../ui/IconButton'; import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { CloseIcon } from '../ui/icons/CloseIcon'; +import { EyeIcon } from '../ui/icons/EyeIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { RouteIcon } from '../ui/icons/RouteIcon'; import { formatFilterValue } from '../../lib/format'; @@ -15,6 +16,8 @@ interface TravelTimeCardProps { label: string; timeRange: [number, number] | null; dataRange: [number, number] | null; + isPinned: boolean; + onTogglePin: () => void; onSetDestination: (slug: string, label: string) => void; onTimeRangeChange: (range: [number, number]) => void; onRemove: () => void; @@ -26,6 +29,8 @@ export function TravelTimeCard({ label, timeRange, dataRange, + isPinned, + onTogglePin, onSetDestination, onTimeRangeChange, onRemove, @@ -59,7 +64,7 @@ export function TravelTimeCard({ const displayRange = timeRange ?? [sliderMin, sliderMax]; return ( -
+
{/* Header */}
@@ -68,9 +73,16 @@ export function TravelTimeCard({ Travel Time ({MODE_LABELS[mode]})
- onRemove()} title="Remove travel time"> - - +
+ {slug && ( + + + + )} + onRemove()} title="Remove travel time"> + + +
{/* Destination search */} @@ -81,6 +93,7 @@ export function TravelTimeCard({ placeholder={slug ? 'Change destination...' : 'Search destination...'} size="xs" inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400" + portal /> {slug && label && ( diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 260b289..f2b5687 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { CheckIcon } from '../ui/icons/CheckIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { AuthUser } from '../../hooks/useAuth'; @@ -9,7 +9,7 @@ const FEATURES = [ '56 data layers across England', 'Every postcode scored and filterable', 'Unlimited map exploration and exports', - 'Historical price data back to 1995', + 'Multiple decades of historical price data', 'Crime, schools, transport, broadband & more', 'All future data updates included', ]; @@ -50,6 +50,23 @@ export default function PricingPage({ }) { const license = useLicense(); const [pricing, setPricing] = useState(null); + const [loading, setLoading] = useState(true); + const [scrolledLeft, setScrolledLeft] = useState(false); + const scrollRef = useRef(null); + const activeCardRef = useRef(null); + const onScroll = useCallback(() => { + 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')) @@ -58,7 +75,8 @@ export default function PricingPage({ return res.json(); }) .then(setPricing) - .catch((err) => console.error('Failed to load pricing:', err)); + .catch((err) => console.error('Failed to load pricing:', err)) + .finally(() => setLoading(false)); }, []); const isLicensed = user?.subscription === 'licensed' || user?.isAdmin; @@ -80,170 +98,254 @@ export default function PricingPage({ } } + const ctaButton = isLicensed ? ( + + ) : user ? ( + + ) : ( + + ); + return ( -
-
-
-

- 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. -

-
+
+ {/* Aurora — sized divs with oklch gradients, no blur */} +
+ {/* Green curtain — top left */} +
+ {/* Teal sweep — center */} +
+ {/* Purple curtain — right */} +
+ {/* Deep violet — bottom right */} +
+ {/* Emerald — bottom left */} +
+ {/* Cyan accent — upper center */} +
+
-
- {/* Price header */} -
-
- Lifetime License -
-
- - {pricing ? formatPrice(currentPrice) : '...'} - - {!isFree && ( - /once - )} -
- {spotsRemaining > 0 && pricing && ( -

- {spotsRemaining} spot{spotsRemaining !== 1 ? 's' : ''}{' '} - remaining at this price -

- )} -

- {isFree - ? 'Free for early adopters' - : 'One-time payment, no subscription'} -

+
+

+ 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. +

+
+ +
+ {/* Tier cards — full viewport width carousel */} + {loading ? ( +
+
+ ) : pricing ? ( +
+ {scrolledLeft &&
} +
+
+ {pricing.tiers.map((tier, i) => { + const isCurrent = i === currentTierIndex; + const isFilled = + tier.up_to !== null && + pricing.licensed_count >= tier.up_to; + const filledInTier = isCurrent + ? pricing.licensed_count - + (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0) + : 0; + const tierSlots = tier.slots; + const fillPercent = isFilled + ? 100 + : isCurrent && tierSlots > 0 + ? (filledInTier / tierSlots) * 100 + : 0; -
- {/* Tier breakdown */} - {pricing && ( -
-

- Pricing tiers -

- {pricing.tiers.map((tier, i) => { - const isCurrent = i === currentTierIndex; - const isFilled = - tier.up_to !== null && - pricing.licensed_count >= tier.up_to; - const filledInTier = isCurrent - ? pricing.licensed_count - - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0) - : 0; - const tierSlots = tier.slots; - const fillPercent = isFilled - ? 100 - : isCurrent && tierSlots > 0 - ? (filledInTier / tierSlots) * 100 - : 0; + return ( +
+ {isCurrent && ( +
+ Current tier +
+ )} - return ( -
+

+ {tierLabel(tier, i)} +

+
- {tierLabel(tier, i)} + {formatPrice(tier.price_pence)} -
- {isCurrent && tierSlots > 0 && ( -
-
-
- )} + {tier.price_pence > 0 && ( - {formatPrice(tier.price_pence)} + /lifetime - {isFilled && ( - - )} -
+ )}
- ); - })} -
- )} - {/* Features list */} -
    - {FEATURES.map((feature) => ( -
  • - - - {feature} - -
  • - ))} -
+ {isCurrent && spotsRemaining > 0 && ( +

+ {spotsRemaining} spot + {spotsRemaining !== 1 ? 's' : ''} remaining +

+ )} + {isFilled && ( +

+ Filled +

+ )} +
- {isLicensed ? ( - - ) : user ? ( - - ) : ( - - )} + {/* Progress bar for current tier */} + {isCurrent && tierSlots > 0 && ( +
+
+
+ )} - {license.error && ( -

- {license.error} -

- )} -

- {isFree - ? 'No credit card required' - : '30-day money-back guarantee'} -

+
+
    + {FEATURES.map((feature) => ( +
  • + + + {feature} + +
  • + ))} +
+ + {isCurrent ? ( + <> + {ctaButton} + {license.error && ( +

+ {license.error} +

+ )} +

+ {isFree + ? 'No credit card required' + : '30-day money-back guarantee'} +

+ + ) : isFilled ? ( +
+ Sold out +
+ ) : ( +
+ Upcoming +
+ )} +
+
+ ); + })} +
-
+ ) : ( +

+ Failed to load pricing. Please try again later. +

+ )}
); diff --git a/frontend/src/components/saved-searches/SavedSearchesPage.tsx b/frontend/src/components/saved-searches/SavedSearchesPage.tsx deleted file mode 100644 index 97a679f..0000000 --- a/frontend/src/components/saved-searches/SavedSearchesPage.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { SavedSearch } from '../../hooks/useSavedSearches'; -import { shortenUrl } from '../../lib/api'; -import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; -import { TrashIcon } from '../ui/icons/TrashIcon'; -import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; -import { CloseIcon } from '../ui/icons/CloseIcon'; -import { formatRelativeTime } from '../../lib/format'; -import { summarizeParams } from '../../lib/url-state'; - -export default function SavedSearchesPage({ - searches, - loading, - onDelete, - onOpen, -}: { - searches: SavedSearch[]; - loading: boolean; - onDelete: (id: string) => Promise; - onOpen: (params: string) => void; -}) { - const [deleteConfirmId, setDeleteConfirmId] = useState(null); - const [copiedId, setCopiedId] = useState(null); - const [sharingId, setSharingId] = useState(null); - - const handleDeleteConfirm = useCallback(async () => { - if (!deleteConfirmId) return; - await onDelete(deleteConfirmId); - setDeleteConfirmId(null); - }, [deleteConfirmId, onDelete]); - - const copyToClipboard = useCallback((text: string, id: string) => { - const onSuccess = () => { - setCopiedId(id); - setTimeout(() => setCopiedId(null), 2000); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).then(onSuccess); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - onSuccess(); - } - }, []); - - const handleShare = useCallback(async (params: string, id: string) => { - setSharingId(id); - try { - const shortUrl = await shortenUrl(params); - copyToClipboard(shortUrl, id); - } catch { - copyToClipboard(`${window.location.origin}/?${params}`, id); - } finally { - setSharingId(null); - } - }, [copyToClipboard]); - - return ( -
-
-

Saved Searches

- - {loading ? ( -
- -
- ) : searches.length === 0 ? ( -
- -

- No saved searches yet -

-

- Save your dashboard filters and view to quickly return to them later. -

-
- ) : ( -
- {searches.map((search) => ( -
- {search.screenshotUrl ? ( - {search.name} - ) : ( -
- -
- )} - -
-

- {search.name} -

-

- {formatRelativeTime(search.created)} -

-

- {summarizeParams(search.params)} -

- -
- - - -
-
-
- ))} -
- )} -
- - {/* Delete confirmation dialog */} - {deleteConfirmId && ( -
setDeleteConfirmId(null)} - > -
-
e.stopPropagation()} - > -
-

Delete search

- -
-

- Are you sure you want to delete this saved search? This cannot be undone. -

-
- - -
-
-
- )} -
- ); -} diff --git a/frontend/src/components/support/SupportPage.tsx b/frontend/src/components/support/SupportPage.tsx deleted file mode 100644 index 7a3253d..0000000 --- a/frontend/src/components/support/SupportPage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState, useCallback } from 'react'; -import { ChevronIcon } from '../ui/icons/ChevronIcon'; - -const FAQ_ITEMS = [ - { - question: 'What data is included?', - answer: - 'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.', - }, - { - question: 'What can I access on the free tier?', - answer: - 'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need a lifetime license.', - }, - { - question: 'What does "lifetime" mean?', - answer: - 'Your license never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.', - }, - { - question: 'Can I get a refund?', - answer: - 'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.', - }, - { - question: 'How often is the data updated?', - answer: - 'We update the data regularly as new Land Registry, EPC, crime, and other government datasets are published. Updates are typically quarterly. All updates are included with your license at no extra cost.', - }, -]; - -function FAQItem({ question, answer }: { question: string; answer: string }) { - const [open, setOpen] = useState(false); - - return ( -
- - {open && ( -
-

{answer}

-
- )} -
- ); -} - -export default function SupportPage() { - return ( -
-
-
-

- Support & FAQ -

-

- Have a question? Check below or reach out to us directly. -

-
- - {/* Contact */} -
-

Need help? Email us at

- - support@propertymap.co.uk - -

- We typically respond within 24 hours. -

-
- - {/* FAQ */} -
-
-

- Frequently Asked Questions -

-
- {FAQ_ITEMS.map((item) => ( - - ))} -
-
-
- ); -} diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index a35ae18..271bda2 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -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({ Continue with Google -
{/* Divider */} diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index a028703..e55ec2e 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -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({ {user && ( )} - - + {user?.subscription !== 'licensed' && !user?.isAdmin && ( + + )} )}
@@ -203,7 +202,7 @@ export default function Header({ {!isMobile && ( <> {user ? ( - + ) : ( <>