diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml index 991aa00..10a3b89 100644 --- a/.forgejo/workflows/docker-publish.yml +++ b/.forgejo/workflows/docker-publish.yml @@ -6,10 +6,6 @@ on: tags: ["v*"] workflow_dispatch: -env: - REGISTRY: ${{ gitea.server_url }} - IMAGE_NAME: ${{ gitea.repository }} - jobs: build-and-push: runs-on: docker @@ -27,54 +23,64 @@ jobs: - name: Set up Docker Buildx uses: https://github.com/docker/setup-buildx-action@v3 + - name: Resolve registry vars + id: registry + run: | + host="${{ gitea.server_url }}" + host="${host#https://}" + host="${host#http://}" + repo=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]') + { + echo "host=${host}" + echo "image=${host}/${repo}" + echo "screenshot_image=${host}/${repo}-screenshot" + } >> "$GITHUB_OUTPUT" + - name: Log in to Forgejo Container Registry uses: https://github.com/docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ steps.registry.outputs.host }} username: ${{ gitea.actor }} password: ${{ secrets.GITEA_TOKEN }} - - name: Determine image tags - id: tags - run: | - REPO=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') - SHA_SHORT=$(echo "${{ gitea.sha }}" | cut -c1-7) - TAGS="${{ env.REGISTRY }}/${REPO}:sha-${SHA_SHORT}" + - name: Extract metadata (main) + id: meta + uses: https://github.com/docker/metadata-action@v5 + with: + images: ${{ steps.registry.outputs.image }} + tags: | + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} - # Add latest tag on default branch - if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then - TAGS="${TAGS},${{ env.REGISTRY }}/${REPO}:latest" - fi - - # Add version tags for semver tags - REF="${{ gitea.ref }}" - if [[ "$REF" =~ ^refs/tags/v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" - MINOR="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" - TAGS="${TAGS},${{ env.REGISTRY }}/${REPO}:${VERSION}" - TAGS="${TAGS},${{ env.REGISTRY }}/${REPO}:${MINOR}" - fi - - echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" - echo "repo=${REPO}" >> "$GITHUB_OUTPUT" - echo "sha_short=${SHA_SHORT}" >> "$GITHUB_OUTPUT" - - - name: Build and push + - name: Build and push (main) uses: https://github.com/docker/build-push-action@v6 with: context: . push: true - tags: ${{ steps.tags.outputs.tags }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}:buildcache,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache,mode=max - - name: Build and push screenshot service + - name: Extract metadata (screenshot) + id: meta-screenshot + uses: https://github.com/docker/metadata-action@v5 + with: + images: ${{ steps.registry.outputs.screenshot_image }} + tags: | + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push (screenshot) uses: https://github.com/docker/build-push-action@v6 with: context: ./screenshot push: true - tags: | - ${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:latest - ${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:sha-${{ steps.tags.outputs.sha_short }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache,mode=max + tags: ${{ steps.meta-screenshot.outputs.tags }} + labels: ${{ steps.meta-screenshot.outputs.labels }} + cache-from: type=registry,ref=${{ steps.registry.outputs.screenshot_image }}:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.screenshot_image }}:buildcache,mode=max diff --git a/.gitignore b/.gitignore index abfeff5..4db6eec 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ frontend/public/assets/* frontend/public/assets/.done server-rs/logs video/auth.* +*.jpg +*.jpeg +*.mp4 + diff --git a/Dockerfile b/Dockerfile index b1385ee..08b3098 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,6 @@ WORKDIR /app COPY --from=server /app/server-rs/target/release/property-map-server ./ COPY --from=frontend /app/frontend/dist ./frontend/dist/ -# Data is provided via volume mounts at runtime — not baked into the image. -# Mount points: -# /app/data - properties.parquet, postcode.parquet, filtered_uk_pois.parquet, places.parquet, uk.pmtiles, postcode_boundaries/, travel-times/ VOLUME ["/app/data"] RUN chown -R appuser:appuser /app diff --git a/Makefile.data b/Makefile.data index f63d5d2..a6468a5 100644 --- a/Makefile.data +++ b/Makefile.data @@ -66,9 +66,17 @@ MAP_ASSETS_STAMP := $(MAP_ASSETS_DIR)/.done PMTILES_VERSION := 1.22.3 +VALIDATE_OUTPUTS := uv run python -m pipeline.validate_outputs + POI_PROXIMITY_DEPS := pipeline/transform/poi_proximity.py pipeline/utils/poi_counts.py MERGE_DEPS := pipeline/transform/merge.py +PRICE_INDEX_DEPS := pipeline/transform/price_estimation/index.py pipeline/transform/price_estimation/shrinkage.py pipeline/transform/price_estimation/utils.py +PRICE_ESTIMATE_DEPS := pipeline/transform/price_estimation/estimate.py pipeline/transform/price_estimation/knn.py pipeline/transform/price_estimation/utils.py TREE_DENSITY_DEPS := pipeline/transform/tree_density.py +CRIME_DOWNLOAD_DEPS := pipeline/download/crime.py +INSPIRE_DOWNLOAD_DEPS := pipeline/download/inspire.py +TRANSIT_DOWNLOAD_DEPS := pipeline/download/transit_network.py pipeline/download/transxchange2gtfs_shim.js +MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_poi.py # ── Phony aliases ───────────────────────────────────────────────────────────── @@ -82,14 +90,17 @@ TREE_DENSITY_DEPS := pipeline/transform/tree_density.py transform-school-proximity transform-tree-density \ generate-postcode-boundaries generate-travel-times -prepare: $(PRICES_STAMP) download-places tiles generate-postcode-boundaries download-map-assets generate-travel-times -merge: $(MERGE_STAMP) +prepare: $(PRICES_STAMP) download-places tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) +merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) tiles: $(TILES) download-arcgis: $(ARCGIS) download-price-paid: $(PRICE_PAID) download-deprivation: $(IOD) download-ethnicity: $(ETHNICITY) download-crime: $(CRIME_STAMP) + $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*.csv" --zip-glob "$(CRIME_DIR)/_archives::*.zip" download-naptan: $(NAPTAN) download-pois: $(POIS_RAW) download-grocery-retail-points: $(GROCERY_RETAIL_POINTS) @@ -99,9 +110,11 @@ download-postcodes: $(POSTCODES_RAW) download-rental-prices: $(RENTAL) download-noise: $(NOISE) download-inspire: $(INSPIRE_STAMP) + $(VALIDATE_OUTPUTS) --dir $(INSPIRE_DIR) --zip-glob "$(INSPIRE_DIR)::*.zip" download-oa-boundaries: $(OA_BOUNDARIES) download-uprn-lookup: $(UPRN_LOOKUP) download-transit-network: $(TRANSIT_STAMP) + $(VALIDATE_OUTPUTS) --file $(TRANSIT_DIR)/raw/england.osm.pbf --zip $(TRANSIT_DIR)/bods_gtfs.zip --zip $(TRANSIT_DIR)/tfl_gtfs.zip download-greenspace: $(GREENSPACE) download-os-greenspace: $(OS_GREENSPACE) download-pbf: $(PBF) @@ -114,13 +127,15 @@ download-election-results: $(ELECTION) download-england-boundary: $(ENGLAND_BOUNDARY) download-rightmove-outcodes: $(RM_OUTCODES) download-map-assets: $(MAP_ASSETS_STAMP) + $(VALIDATE_OUTPUTS) --file $(MAP_ASSETS_DIR)/sprites/light.json --file $(MAP_ASSETS_DIR)/sprites/light.png --file $(MAP_ASSETS_DIR)/sprites/dark.json --file $(MAP_ASSETS_DIR)/sprites/dark.png --glob "$(MAP_ASSETS_DIR)/fonts::**/*.pbf" --glob "$(MAP_ASSETS_DIR)/twemoji::*.png" --glob "$(MAP_ASSETS_DIR)/poi-icons::**/*" transform-pois: $(POIS_FILTERED) transform-epc-pp: $(EPC_PP) transform-crime: $(CRIME) transform-poi-proximity: $(POI_PROXIMITY) transform-school-proximity: $(SCHOOL_PROX) -transform-tree-density: $(TREE_DENSITY_ADDR) +transform-tree-density: $(TREE_DENSITY_PC) generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) + $(VALIDATE_OUTPUTS) --dir $(INSPIRE_DIR) --zip-glob "$(INSPIRE_DIR)::*.zip" uv run python -m pipeline.transform.postcode_boundaries \ --uprn $(UPRN_LOOKUP) \ --oa-boundaries $(OA_BOUNDARIES) \ @@ -158,11 +173,13 @@ $(PRICE_PAID): $(IOD): uv run python -m pipeline.download.deprivation_data --output $@ -$(ETHNICITY): +$(ETHNICITY): pipeline/download/ethnicity.py uv run python -m pipeline.download.ethnicity --output $@ -$(CRIME_STAMP): +$(CRIME_STAMP): $(CRIME_DOWNLOAD_DEPS) + @rm -f $@ uv run python -m pipeline.download.crime --output $(CRIME_DIR) + $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*.csv" --zip-glob "$(CRIME_DIR)/_archives::*.zip" @touch $@ $(NAPTAN): @@ -183,7 +200,7 @@ $(OFS_REGISTER): curl -fL -A "Mozilla/5.0" -o $@.tmp https://register-api.officeforstudents.org.uk/api/Download/ mv $@.tmp $@ -$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY) +$(POIS_RAW): $(PBF) $(ENGLAND_BOUNDARY) pipeline/download/pois.py uv run python -m pipeline.download.pois --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY) $(GROCERY_RETAIL_POINTS): @@ -198,11 +215,13 @@ $(BROADBAND): $(POSTCODES_RAW): uv run python -m pipeline.download.postcodes --output $@ -$(NOISE): $(ARCGIS) +$(NOISE): $(ARCGIS) pipeline/download/noise.py uv run python -m pipeline.download.noise --arcgis $(ARCGIS) --output $@ -$(INSPIRE_STAMP): +$(INSPIRE_STAMP): $(INSPIRE_DOWNLOAD_DEPS) + @rm -f $@ uv run python -m pipeline.download.inspire --output $(INSPIRE_DIR) + $(VALIDATE_OUTPUTS) --dir $(INSPIRE_DIR) --zip-glob "$(INSPIRE_DIR)::*.zip" @touch $@ $(OA_BOUNDARIES): @@ -211,11 +230,13 @@ $(OA_BOUNDARIES): $(UPRN_LOOKUP): uv run python -m pipeline.download.uprn_lookup --output $@ -$(TRANSIT_STAMP): +$(TRANSIT_STAMP): $(TRANSIT_DOWNLOAD_DEPS) + @rm -f $@ uv run python -m pipeline.download.transit_network --output $(TRANSIT_DIR) + $(VALIDATE_OUTPUTS) --file $(TRANSIT_DIR)/raw/england.osm.pbf --zip $(TRANSIT_DIR)/bods_gtfs.zip --zip $(TRANSIT_DIR)/tfl_gtfs.zip @touch $@ -$(RENTAL): +$(RENTAL): pipeline/download/rental_prices.py uv run python -m pipeline.download.rental_prices --output $@ $(GREENSPACE): $(PBF) @@ -234,7 +255,7 @@ $(LSOA_POP): $(MEDIAN_AGE): uv run python -m pipeline.download.median_age --output $@ -$(ELECTION): +$(ELECTION): pipeline/download/election_results.py uv run python -m pipeline.download.election_results --output $@ $(ENGLAND_BOUNDARY): @@ -243,8 +264,10 @@ $(ENGLAND_BOUNDARY): $(RM_OUTCODES): $(MERGE_STAMP) uv run python -m pipeline.download.rightmove_outcodes --postcodes $(POSTCODES_PQ) --output $@ -$(MAP_ASSETS_STAMP): +$(MAP_ASSETS_STAMP): $(MAP_ASSETS_DEPS) + @rm -f $@ uv run python -m pipeline.download.map_assets --output $(MAP_ASSETS_DIR) + $(VALIDATE_OUTPUTS) --file $(MAP_ASSETS_DIR)/sprites/light.json --file $(MAP_ASSETS_DIR)/sprites/light.png --file $(MAP_ASSETS_DIR)/sprites/dark.json --file $(MAP_ASSETS_DIR)/sprites/dark.png --glob "$(MAP_ASSETS_DIR)/fonts::**/*.pbf" --glob "$(MAP_ASSETS_DIR)/twemoji::*.png" --glob "$(MAP_ASSETS_DIR)/poi-icons::**/*" @touch $@ # ── Transforms ──────────────────────────────────────────────────────────────── @@ -252,10 +275,11 @@ $(MAP_ASSETS_STAMP): $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(ENGLAND_BOUNDARY) uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --boundary $(ENGLAND_BOUNDARY) --grocery-retail-points $(GROCERY_RETAIL_POINTS) --output $@ -$(EPC_PP): $(PRICE_PAID) $(EPC) +$(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ $(CRIME): $(CRIME_STAMP) + $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*.csv" --zip-glob "$(CRIME_DIR)/_archives::*.zip" uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $@ $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DEPS) @@ -264,14 +288,14 @@ $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DE $(SCHOOL_PROX): $(OFSTED) $(ARCGIS) uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@ -$(TREE_DENSITY_ADDR): $(FR_TOW) $(ARCGIS) $(PRICE_PAID) $(TREE_DENSITY_DEPS) +$(TREE_DENSITY_PC): $(FR_TOW) $(ARCGIS) $(PRICE_PAID) $(TREE_DENSITY_DEPS) uv run python -m pipeline.transform.tree_density \ --tow-zip $(FR_TOW) \ --arcgis $(ARCGIS) \ --price-paid $(PRICE_PAID) \ --output-postcodes $(TREE_DENSITY_PC) \ --output-streets $(TREE_DENSITY_STREETS) \ - --output-addresses $@ + --output-addresses $(TREE_DENSITY_ADDR) # Postcode boundaries require manual generation — fail with instructions $(PC_BOUNDARIES): @@ -291,7 +315,8 @@ $(PC_BOUNDARIES): # ── Final merge → postcode.parquet + properties.parquet ────────────────────── $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ - $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_ADDR) $(MERGE_DEPS) + $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(RENTAL) $(LSOA_POP) $(MEDIAN_AGE) $(ELECTION) $(TREE_DENSITY_PC) $(MERGE_DEPS) + @rm -f $@ uv run python -m pipeline.transform.merge \ --epc-pp $(EPC_PP) \ --arcgis $(ARCGIS) \ @@ -306,16 +331,23 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ --lsoa-population $(LSOA_POP) \ --median-age $(MEDIAN_AGE) \ --election-results $(ELECTION) \ - --tree-density-addresses $(TREE_DENSITY_ADDR) \ + --tree-density-postcodes $(TREE_DENSITY_PC) \ --output-postcodes $(POSTCODES_PQ) \ --output-properties $(PROPERTIES_PQ) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) @touch $@ # ── Price estimation (post-merge) ─────────────────────────────────────────── -$(PRICE_INDEX): $(MERGE_STAMP) - uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@ +$(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) -$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) +$(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) + uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@ + $(VALIDATE_OUTPUTS) --parquet $@ + +$(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) + @rm -f $@ uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) + $(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) @touch $@ diff --git a/price_model.ipynb b/analyses/price_model.ipynb similarity index 100% rename from price_model.ipynb rename to analyses/price_model.ipynb diff --git a/docker-compose.yml b/docker-compose.yml index 482d778..eb2f502 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,13 @@ x-credentials: services: server: image: rust:1.84 - init: true tty: true stdin_open: true working_dir: /app/server-rs command: > bash -c " cargo install cargo-watch && - cargo watch -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.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 --poll -i logs/ -x 'run -- --properties /app/data/properties.parquet --postcode-features /app/data/postcode.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" @@ -39,7 +38,7 @@ services: SCREENSHOT_URL: http://screenshot:8002 GEMINI_API_KEY: AIzaSyC2mQDcEwILHM3uOE2C-lxUQbQrKTX9Xi4 GEMINI_MODEL: gemini-3-flash-preview - PUBLIC_URL: https://perfect-postcodes.co.uk + PUBLIC_URL: http://localhost:3001 GOOGLE_MAPS_API_KEY: "AIzaSyBgBn9LjrxHCjb9j1LZbLYpEdCJj-NkHPY" STRIPE_SECRET_KEY: sk_test_51SyVcePRjj2bdyn1HLkatQ5onwp8kamm41tjMcRdxXnJYWVPsVd9usMTOSNtNdGhrjbsrtNbgTdKXICg2qBiocEn00PvNDC0d3 STRIPE_WEBHOOK_SECRET: whsec_pIkGZblYlcN2VesTxq4pk1cDqdxOQ1y0 @@ -59,6 +58,7 @@ services: PORT: "8002" APP_URL: http://frontend:3001 CACHE_DIR: /cache + SCREENSHOT_CACHE_ENABLED: "false" SCREENSHOT_CONCURRENCY: "3" SCREENSHOT_RATE_WINDOW_MS: "60000" SCREENSHOT_RATE_LIMIT: "30" diff --git a/frontend/public/video/recording-de-mobile.jpg b/frontend/public/video/recording-de-mobile.jpg new file mode 100644 index 0000000..5f734ea Binary files /dev/null and b/frontend/public/video/recording-de-mobile.jpg differ diff --git a/frontend/public/video/recording-de-mobile.mp4 b/frontend/public/video/recording-de-mobile.mp4 new file mode 100644 index 0000000..cc88ce0 Binary files /dev/null and b/frontend/public/video/recording-de-mobile.mp4 differ diff --git a/frontend/public/video/recording-de.jpg b/frontend/public/video/recording-de.jpg index e4c932f..5714cf5 100644 Binary files a/frontend/public/video/recording-de.jpg and b/frontend/public/video/recording-de.jpg differ diff --git a/frontend/public/video/recording-de.mp4 b/frontend/public/video/recording-de.mp4 index 1397be5..2b9f98f 100644 Binary files a/frontend/public/video/recording-de.mp4 and b/frontend/public/video/recording-de.mp4 differ diff --git a/frontend/public/video/recording-hi-mobile.jpg b/frontend/public/video/recording-hi-mobile.jpg new file mode 100644 index 0000000..8ae9277 Binary files /dev/null and b/frontend/public/video/recording-hi-mobile.jpg differ diff --git a/frontend/public/video/recording-hi-mobile.mp4 b/frontend/public/video/recording-hi-mobile.mp4 new file mode 100644 index 0000000..e2d55d8 Binary files /dev/null and b/frontend/public/video/recording-hi-mobile.mp4 differ diff --git a/frontend/public/video/recording-hi.jpg b/frontend/public/video/recording-hi.jpg index 11f2749..c5454b3 100644 Binary files a/frontend/public/video/recording-hi.jpg and b/frontend/public/video/recording-hi.jpg differ diff --git a/frontend/public/video/recording-hi.mp4 b/frontend/public/video/recording-hi.mp4 index 65a17da..b832576 100644 Binary files a/frontend/public/video/recording-hi.mp4 and b/frontend/public/video/recording-hi.mp4 differ diff --git a/frontend/public/video/recording-mobile.jpg b/frontend/public/video/recording-mobile.jpg new file mode 100644 index 0000000..a648e72 Binary files /dev/null and b/frontend/public/video/recording-mobile.jpg differ diff --git a/frontend/public/video/recording-mobile.mp4 b/frontend/public/video/recording-mobile.mp4 new file mode 100644 index 0000000..dacf313 Binary files /dev/null and b/frontend/public/video/recording-mobile.mp4 differ diff --git a/frontend/public/video/recording-zh-mobile.jpg b/frontend/public/video/recording-zh-mobile.jpg new file mode 100644 index 0000000..e38d3f0 Binary files /dev/null and b/frontend/public/video/recording-zh-mobile.jpg differ diff --git a/frontend/public/video/recording-zh-mobile.mp4 b/frontend/public/video/recording-zh-mobile.mp4 new file mode 100644 index 0000000..38a6c07 Binary files /dev/null and b/frontend/public/video/recording-zh-mobile.mp4 differ diff --git a/frontend/public/video/recording-zh.jpg b/frontend/public/video/recording-zh.jpg index e4a5a6c..cab338e 100644 Binary files a/frontend/public/video/recording-zh.jpg and b/frontend/public/video/recording-zh.jpg differ diff --git a/frontend/public/video/recording-zh.mp4 b/frontend/public/video/recording-zh.mp4 index 7ff5fd1..d4bb2a6 100644 Binary files a/frontend/public/video/recording-zh.mp4 and b/frontend/public/video/recording-zh.mp4 differ diff --git a/frontend/public/video/recording.jpg b/frontend/public/video/recording.jpg index 65e76df..c549445 100644 Binary files a/frontend/public/video/recording.jpg and b/frontend/public/video/recording.jpg differ diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index 698c184..6b1f56e 100644 Binary files a/frontend/public/video/recording.mp4 and b/frontend/public/video/recording.mp4 differ diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index 49638b9..ec1fdcb 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -12,6 +12,8 @@ // 5. The lazy locale loader map covers every non-English supported language. // 6. Selected visible UI strings that previously slipped through are not // hardcoded outside the i18n files. +// 7. Server-derived feature/group names from server-rs/src/features.rs are +// present in en.ts > server so they can be translated. // // The script parses the TypeScript source with the compiler API and walks the // AST — no runtime import, no transpilation, no temp files. Run it with: @@ -26,6 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const I18N_DIR = join(__dirname, '..', 'src', 'i18n'); const LOCALES_DIR = join(I18N_DIR, 'locales'); const SRC_DIR = join(__dirname, '..', 'src'); +const FEATURES_RS = join(__dirname, '..', '..', 'server-rs', 'src', 'features.rs'); const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g; const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g; @@ -78,9 +81,6 @@ const SAME_AS_EN_VALUE_ALLOWLIST = new Set([ const FORBIDDEN_VISIBLE_STRINGS = [ ['without this filter', 'filters.filtersOut'], ['Connecting to server...', 'common.connectingToServer'], - ['Property saved!', 'toasts.propertySaved'], - ['View saved', 'toasts.viewSaved'], - ["Don't show again", 'toasts.dontShowAgain'], ['Close pane', 'common.closePane'], ['Points of interest', 'poiPane.pointsOfInterest'], ['No data', 'common.noData'], @@ -204,6 +204,26 @@ function readLocale(code) { return obj; } +function readServerFeatureNames() { + const src = readFileSync(FEATURES_RS, 'utf8'); + const names = []; + const re = /\bname:\s*"((?:\\.|[^"\\])*)"/g; + for (const match of src.matchAll(re)) { + names.push(JSON.parse(`"${match[1]}"`)); + } + return [...new Set(names)]; +} + +function readServerFeatureConfigNames() { + const src = readFileSync(FEATURES_RS, 'utf8'); + const names = []; + const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g; + for (const match of src.matchAll(re)) { + names.push(JSON.parse(`"${match[1]}"`)); + } + return [...new Set(names)]; +} + function readNamedRecord(file, varName) { const sf = parseFile(join(I18N_DIR, file)); const init = findVarInitializer(sf, varName); @@ -335,7 +355,7 @@ function checkLocales(supportedCodes) { } } -function checkRecordCoverage(file, varName, supportedCodes, serverKeys) { +function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) { const record = readNamedRecord(file, varName); const expected = supportedCodes.filter((c) => c !== 'en'); const present = Object.keys(record); @@ -357,6 +377,12 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) { if (record[code]) for (const k of Object.keys(record[code])) union.add(k); } + for (const key of requiredKeys) { + if (!union.has(key)) { + fail(`${file}: missing translations for API feature "${key}"`); + } + } + for (const code of expected) { const langKeys = new Set(Object.keys(record[code] ?? {})); for (const key of union) { @@ -380,6 +406,14 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) { } } +function checkServerSourceCoverage(serverKeys) { + for (const name of readServerFeatureNames()) { + if (!serverKeys.has(name)) { + fail(`en.ts > server is missing API feature/group name "${name}" from features.rs`); + } + } +} + function collectSourceFiles(dir, out = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); @@ -444,8 +478,16 @@ function main() { checkLocaleLoaders(supportedCodes); const en = readLocale('en'); const serverKeys = new Set(Object.keys(en.server ?? {})); - checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys); - checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys); + const sourceFeatureKeys = readServerFeatureConfigNames(); + checkServerSourceCoverage(serverKeys); + checkRecordCoverage( + 'descriptions.ts', + 'descriptions', + supportedCodes, + serverKeys, + sourceFeatureKeys + ); + checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys, sourceFeatureKeys); checkForbiddenVisibleStrings(); for (const w of warnings) console.warn(`warn: ${w}`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12fc9fa..0e0a0c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,16 +12,16 @@ import { } from './lib/seoRoutes'; import Header, { type Page } from './components/ui/Header'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; -import { fetchWithRetry, apiUrl } from './lib/api'; +import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api'; import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import { INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; import { useIsMobile } from './hooks/useIsMobile'; import { useAuth } from './hooks/useAuth'; +import { useLicense } from './hooks/useLicense'; import { useTelemetry } from './hooks/useTelemetry'; import { useSavedSearches } from './hooks/useSavedSearches'; -import { useSavedProperties } from './hooks/useSavedProperties'; declare global { interface Window { @@ -39,9 +39,6 @@ const AccountPage = lazy(() => import('./components/account/AccountPage')); const SavedPage = lazy(() => import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage })) ); -const InvitesPage = lazy(() => - import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage })) -); const InvitePage = lazy(() => import('./components/invite/InvitePage')); const MapPage = lazy(() => import('./components/map/MapPage')); const AuthModal = lazy(() => import('./components/ui/AuthModal')); @@ -52,6 +49,49 @@ function PageFallback() { return
; } +interface RouteMatch { + page: Page; + inviteCode?: string; + hash?: string; +} + +type PostAuthIntent = 'checkout'; +type LicenseSuccessStatus = 'hidden' | 'verifying' | 'success' | 'delayed'; + +const LICENSE_VERIFICATION_ATTEMPTS = 8; +const LICENSE_VERIFICATION_DELAY_MS = 1500; + +function hasFullAccess(user?: { subscription?: string; isAdmin?: boolean } | null): boolean { + return user?.subscription === 'licensed' || user?.isAdmin === true; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function normalizeHash(hash?: string | null): string { + return hash?.replace(/^#/, '') ?? ''; +} + +function currentRelativePath(): string { + return `${window.location.pathname}${window.location.search}`; +} + +function isProtectedPage(page: Page): boolean { + return page === 'account' || page === 'saved'; +} + +function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string { + const normalizedHash = normalizeHash(hash); + return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`; +} + +function scrollToHash(hash: string) { + window.requestAnimationFrame(() => { + document.getElementById(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }); +} + function unavailableAuthAction(): never { throw new Error('Authentication actions are not available in this render mode'); } @@ -79,8 +119,6 @@ function pageToPath(page: Page, inviteCode?: string): string { return SEO_CONTENT_PATHS[page]; case 'saved': return '/saved'; - case 'invites': - return '/invites'; case 'account': return '/account'; case 'invite': @@ -93,10 +131,10 @@ function pageToPath(page: Page, inviteCode?: string): string { } } -function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null { +function pathToPage(pathname: string): RouteMatch | null { if (pathname === '/dashboard') return { page: 'dashboard' }; if (pathname === '/saved') return { page: 'saved' }; - if (pathname === '/invites') return { page: 'invites' }; + if (pathname === '/invites') return { page: 'account', hash: 'invites' }; if (pathname === '/learn') return { page: 'learn' }; if (pathname === '/pricing') return { page: 'pricing' }; const seoLandingPage = getSeoLandingPage(pathname); @@ -123,7 +161,14 @@ function isSeoContentPage(page: Page): page is SeoContentKey { export default function App() { const urlState = useMemo(() => parseUrlState(), []); + const initialRoute = useMemo(() => pathToPage(window.location.pathname), []); const [mapUrlState, setMapUrlState] = useState(urlState); + const [dashboardRouteKey, setDashboardRouteKey] = useState(() => + window.location.pathname === '/dashboard' ? window.location.search : '' + ); + const [dashboardParams, setDashboardParams] = useState(() => + window.location.pathname === '/dashboard' ? window.location.search.replace(/^\?/, '') : '' + ); const dashboardSearchRef = useRef( window.location.pathname === '/dashboard' ? window.location.search : '' ); @@ -147,15 +192,16 @@ export default function App() { const [initialLoading, setInitialLoading] = useState(true); const [pendingInfoFeature, setPendingInfoFeature] = useState(null); const [inviteCode, setInviteCode] = useState(() => { - const fromPath = pathToPage(window.location.pathname); - return fromPath?.inviteCode ?? null; + return initialRoute?.inviteCode ?? null; }); + const [routeHash, setRouteHash] = useState( + () => initialRoute?.hash ?? normalizeHash(window.location.hash) + ); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; // Derive page from URL pathname - const fromPath = pathToPage(window.location.pathname); - if (fromPath) return fromPath.page; + if (initialRoute) return initialRoute.page; // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; @@ -182,26 +228,95 @@ export default function App() { refreshAuth, clearError, } = useAuth(); + const { startCheckout: startPostAuthCheckout } = useLicense(); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); - const [showLicenseSuccess, setShowLicenseSuccess] = useState(false); + const [postAuthIntent, setPostAuthIntent] = useState(null); + const postAuthCheckoutReturnPathRef = useRef(null); + const authCompletedRef = useRef(false); + const [licenseSuccessStatus, setLicenseSuccessStatus] = useState('hidden'); + + const openAuthModal = useCallback( + ( + tab: 'login' | 'register', + intent: PostAuthIntent | null = null, + checkoutReturnPath?: string + ) => { + authCompletedRef.current = false; + postAuthCheckoutReturnPathRef.current = + intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null; + setPostAuthIntent(intent); + setAuthModalTab(tab); + setShowAuthModal(true); + clearError(); + }, + [clearError] + ); + + const closeAuthModal = useCallback(() => { + setShowAuthModal(false); + const completed = authCompletedRef.current; + if (!completed) { + setPostAuthIntent(null); + postAuthCheckoutReturnPathRef.current = null; + if (isProtectedPage(activePageRef.current)) { + window.history.replaceState({ page: 'home', hash: '' }, '', '/'); + setRouteHash(''); + setActivePage('home'); + } + } + authCompletedRef.current = false; + }, []); + useEffect(() => { const params = new URLSearchParams(window.location.search); - if (params.get('license_success') === '1') { + const returnedFromCheckout = params.get('license_success') === '1'; + let cancelled = false; + + if (returnedFromCheckout) { params.delete('license_success'); const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; window.history.replaceState({}, '', newUrl); - trackEvent('Purchase'); - setShowLicenseSuccess(true); } - // Always refresh auth on startup to pick up server-side subscription changes - refreshAuth().catch(() => {}); + + async function refreshOnStartup() { + if (!returnedFromCheckout) { + // Always refresh auth on startup to pick up server-side subscription changes. + refreshAuth().catch(() => {}); + return; + } + + setLicenseSuccessStatus('verifying'); + for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) { + try { + const refreshedUser = await refreshAuth(); + if (cancelled) return; + if (hasFullAccess(refreshedUser)) { + trackEvent('Purchase'); + setLicenseSuccessStatus('success'); + return; + } + } catch (error) { + logNonAbortError('Failed to verify license activation', error); + break; + } + + await delay(LICENSE_VERIFICATION_DELAY_MS); + if (cancelled) return; + } + + if (!cancelled) setLicenseSuccessStatus('delayed'); + } + + refreshOnStartup(); + return () => { + cancelled = true; + }; }, []); // eslint-disable-line react-hooks/exhaustive-deps const savedSearches = useSavedSearches(user?.id ?? null); - const savedProperties = useSavedProperties(user?.id ?? null); const [showSaveModal, setShowSaveModal] = useState(false); useEffect(() => { @@ -241,6 +356,7 @@ export default function App() { const navigateTo = useCallback( (page: Page, hash?: string, infoFeature?: string) => { + const targetHash = normalizeHash(hash); // Save dashboard search params before navigating away if (activePageRef.current === 'dashboard') { dashboardSearchRef.current = window.location.search; @@ -248,38 +364,67 @@ export default function App() { if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); } - const path = pageToPath(page, inviteCode ?? undefined); // Restore dashboard search params when navigating back const search = page === 'dashboard' ? dashboardSearchRef.current : ''; - const url = hash ? `${path}${search}#${hash}` : `${path}${search}`; - window.history.pushState({ page }, '', url); + const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash); + window.history.pushState({ page, hash: targetHash }, '', url); if (page === 'dashboard') { setMapUrlState(parseUrlState()); + setDashboardRouteKey(window.location.search); } + setRouteHash(targetHash); setActivePage(page); + if (targetHash) scrollToHash(targetHash); }, [inviteCode] ); + useEffect(() => { + if (authLoading || !user || postAuthIntent !== 'checkout') return; + + setPostAuthIntent(null); + setShowAuthModal(false); + const checkoutReturnPath = postAuthCheckoutReturnPathRef.current ?? undefined; + postAuthCheckoutReturnPathRef.current = null; + if (hasFullAccess(user)) { + if (checkoutReturnPath?.startsWith('/dashboard')) { + window.history.pushState({ page: 'dashboard', hash: '' }, '', checkoutReturnPath); + setMapUrlState(parseUrlState()); + setDashboardRouteKey(window.location.search); + setRouteHash(''); + setActivePage('dashboard'); + } else { + navigateTo('dashboard'); + } + return; + } + + startPostAuthCheckout(checkoutReturnPath).catch((error) => { + logNonAbortError('Failed to resume checkout after auth', error); + navigateTo('pricing'); + }); + }, [authLoading, navigateTo, postAuthIntent, startPostAuthCheckout, user]); + useEffect(() => { activePageRef.current = activePage; }, [activePage]); useEffect(() => { if (!window.history.state?.page) { + const hash = routeHash || normalizeHash(window.location.hash); window.history.replaceState( - { page: activePage }, + { page: activePage, hash }, '', - pageToPath(activePage, inviteCode ?? undefined) + - window.location.search + - window.location.hash + buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash) ); } const handlePopState = (e: PopStateEvent) => { let page: Page; + const hash = normalizeHash(window.location.hash); if (e.state?.page) { page = e.state.page; setActivePage(page); + setRouteHash(hash || e.state.hash || ''); if (e.state.infoFeature) { setPendingInfoFeature(e.state.infoFeature); } @@ -287,11 +432,13 @@ export default function App() { const parsed = pathToPage(window.location.pathname); page = parsed?.page || 'home'; setActivePage(page); + setRouteHash(parsed?.hash ?? hash); if (parsed?.inviteCode) setInviteCode(parsed.inviteCode); } // Re-parse URL state when returning to dashboard via back/forward if (page === 'dashboard') { setMapUrlState(parseUrlState()); + setDashboardRouteKey(window.location.search); } }; window.addEventListener('popstate', handlePopState); @@ -299,30 +446,22 @@ export default function App() { }, []); // eslint-disable-line react-hooks/exhaustive-deps const { fetchSearches } = savedSearches; - const { fetchProperties: fetchSavedProperties } = savedProperties; useEffect(() => { if (activePage === 'saved') { fetchSearches(); - fetchSavedProperties(); } - if (activePage === 'dashboard' && user) { - fetchSavedProperties(); - } - }, [activePage, fetchSearches, fetchSavedProperties, user]); + }, [activePage, fetchSearches]); - const isAuthRequiredPage = - activePage === 'account' || activePage === 'saved' || activePage === 'invites'; + const isAuthRequiredPage = activePage === 'account' || activePage === 'saved'; useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { - setAuthModalTab('login'); - setShowAuthModal(true); - navigateTo('home'); + openAuthModal('login'); } - if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) { + if (activePage === 'pricing' && hasFullAccess(user)) { navigateTo('dashboard'); } - }, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]); + }, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]); const [exportState, setExportState] = useState(null); @@ -372,21 +511,17 @@ export default function App() {
setShowSaveModal(true) : null} savingSearch={savedSearches.saving} user={user} - onLoginClick={() => { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} + onLoginClick={() => openAuthModal('login')} + onRegisterClick={() => openAuthModal('register')} onLogout={logout} isMobile={isMobile} /> @@ -402,14 +537,8 @@ export default function App() { navigateTo('dashboard')} user={user} - onLoginClick={() => { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} + onLoginClick={() => openAuthModal('login', 'checkout', '/pricing')} + onRegisterClick={() => openAuthModal('register', 'checkout', '/pricing')} /> ) : activePage === 'learn' ? ( @@ -427,38 +556,32 @@ export default function App() { onOpenSearch={(params) => { window.location.href = `/dashboard?${params}`; }} - savedProperties={savedProperties.properties} - propertiesLoading={savedProperties.loading} - onDeleteProperty={savedProperties.deleteProperty} - onUpdatePropertyNotes={savedProperties.updatePropertyNotes} - onOpenProperty={(postcode) => { - window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`; - }} /> - ) : activePage === 'invites' && user ? ( - ) : activePage === 'account' && user ? ( - + { + await refreshAuth(); + }} + scrollTarget={routeHash} + /> + ) : isAuthRequiredPage && !user ? ( + ) : activePage === 'invite' && inviteCode ? ( { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} + onLoginClick={() => openAuthModal('login')} + onRegisterClick={() => openAuthModal('register')} onLicenseGranted={() => { - setShowLicenseSuccess(true); + setLicenseSuccessStatus('success'); refreshAuth(); }} /> ) : ( setPendingInfoFeature(null)} onNavigateTo={navigateTo} onExportStateChange={setExportState} + onDashboardParamsChange={setDashboardParams} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} initialPostcode={mapUrlState.postcode} shareCode={mapUrlState.share} user={user} - onLoginClick={() => { - setAuthModalTab('login'); - setShowAuthModal(true); - }} - onRegisterClick={() => { - setAuthModalTab('register'); - setShowAuthModal(true); - }} - onSaveProperty={user ? savedProperties.saveProperty : undefined} - onUnsaveProperty={user ? savedProperties.deleteProperty : undefined} - isPropertySaved={user ? savedProperties.isPropertySaved : undefined} - getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined} - deferTutorial={showLicenseSuccess} + onLoginClick={() => openAuthModal('login')} + onRegisterClick={() => openAuthModal('register')} + onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)} + onCheckoutRegisterClick={(returnPath) => + openAuthModal('register', 'checkout', returnPath) + } + deferTutorial={licenseSuccessStatus !== 'hidden'} onSaveSearch={user ? savedSearches.saveSearch : undefined} savingSearch={savedSearches.saving} /> @@ -497,7 +615,10 @@ export default function App() { {showAuthModal && ( setShowAuthModal(false)} + onClose={closeAuthModal} + onAuthenticated={() => { + authCompletedRef.current = true; + }} onLogin={login} onRegister={register} onOAuthLogin={loginWithOAuth} @@ -511,7 +632,7 @@ export default function App() { {showSaveModal && ( setShowSaveModal(false)} - onSave={savedSearches.saveSearch} + onSave={(name) => savedSearches.saveSearch(name, dashboardParams)} onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); @@ -520,11 +641,13 @@ export default function App() { error={savedSearches.error} /> )} - {showLicenseSuccess && ( + {licenseSuccessStatus !== 'hidden' && ( { - setShowLicenseSuccess(false); - navigateTo('dashboard'); + const shouldOpenDashboard = licenseSuccessStatus === 'success'; + setLicenseSuccessStatus('hidden'); + if (shouldOpenDashboard) navigateTo('dashboard'); }} /> )} diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index d9b5d5e..69a6d77 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -2,8 +2,14 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; -import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties'; -import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api'; +import { + apiUrl, + authHeaders, + assertOk, + shortenUrl, + prewarmScreenshot, + paramsWithLanguage, +} from '../../lib/api'; import { copyToClipboard } from '../../lib/clipboard'; import { formatRelativeTime, formatNumber } from '../../lib/format'; import { summarizeParams } from '../../lib/url-state'; @@ -11,7 +17,6 @@ import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { CheckIcon } from '../ui/icons/CheckIcon'; import { ClipboardIcon } from '../ui/icons/ClipboardIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; -import { HouseIcon } from '../ui/icons/HouseIcon'; import { TrashIcon } from '../ui/icons/TrashIcon'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { useLicense } from '../../hooks/useLicense'; @@ -126,24 +131,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string) ); } -function formatPropertyPrice(data: SavedPropertyData): string | null { - if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`; - if (data.price) return `£${formatNumber(data.price)}`; - return null; -} - -function formatPropertyDetails( - data: SavedPropertyData, - t: { (key: 'savedPage.bed'): string; (key: 'savedPage.epc'): string } -): string { - const parts: string[] = []; - if (data.propertySubType) parts.push(data.propertySubType); - else if (data.propertyType) parts.push(data.propertyType); - if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`); - if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`); - return parts.join(' · '); -} - function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); @@ -213,7 +200,7 @@ function SavedSearchesTab({ onUpdateName: (id: string, name: string) => void; onOpen: (params: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [copiedId, setCopiedId] = useState(null); const [sharingId, setSharingId] = useState(null); @@ -233,18 +220,21 @@ function SavedSearchesTab({ const handleShare = useCallback( async (params: string, id: string) => { - prewarmScreenshot(params); + prewarmScreenshot(params, i18n.language); setSharingId(id); try { - const shortUrl = await shortenUrl(params); + const shortUrl = await shortenUrl(params, i18n.language); doCopy(shortUrl, id); } catch { - doCopy(`${window.location.origin}/?${params}`, id); + doCopy( + `${window.location.origin}/dashboard?${paramsWithLanguage(params, i18n.language)}`, + id + ); } finally { setSharingId(null); } }, - [doCopy] + [doCopy, i18n.language] ); if (loading) { @@ -355,115 +345,6 @@ function SavedSearchesTab({ ); } -function SavedPropertiesTab({ - properties, - loading, - onDelete, - onUpdateNotes, - onOpen, -}: { - properties: SavedProperty[]; - loading: boolean; - onDelete: (id: string) => Promise; - onUpdateNotes: (id: string, notes: string) => void; - onOpen: (postcode: string) => void; -}) { - const { t } = useTranslation(); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); - - const handleDeleteConfirm = useCallback(async () => { - if (!deleteConfirmId) return; - await onDelete(deleteConfirmId); - setDeleteConfirmId(null); - }, [deleteConfirmId, onDelete]); - - if (loading) { - return ( -
- -
- ); - } - - if (properties.length === 0) { - return ( -
- -

- {t('savedPage.noSavedProperties')} -

-

- {t('savedPage.noSavedPropertiesDesc')} -

-
- ); - } - - return ( - <> -
- {properties.map((prop) => { - const price = formatPropertyPrice(prop.data); - const details = formatPropertyDetails(prop.data, t); - return ( -
-
-

- {prop.address} -

-
-

{prop.postcode}

- {price && ( -

{price}

- )} - {details && ( -

{details}

- )} -

- {formatRelativeTime(prop.created)} -

- -
- onUpdateNotes(prop.id, notes)} /> -
- -
-
- - -
-
-
- ); - })} -
- - {deleteConfirmId && ( - setDeleteConfirmId(null)} - onConfirm={handleDeleteConfirm} - /> - )} - - ); -} - export function SavedPage({ searches, searchesLoading, @@ -471,11 +352,6 @@ export function SavedPage({ onUpdateSearchNotes, onUpdateSearchName, onOpenSearch, - savedProperties, - propertiesLoading, - onDeleteProperty, - onUpdatePropertyNotes, - onOpenProperty, }: { searches: SavedSearch[]; searchesLoading: boolean; @@ -483,16 +359,41 @@ export function SavedPage({ onUpdateSearchNotes: (id: string, notes: string) => void; onUpdateSearchName: (id: string, name: string) => void; onOpenSearch: (params: string) => void; - savedProperties: SavedProperty[]; - propertiesLoading: boolean; - onDeleteProperty: (id: string) => Promise; - onUpdatePropertyNotes: (id: string, notes: string) => void; - onOpenProperty: (postcode: string) => void; }) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState<'searches' | 'properties'>( - window.location.hash === '#properties' ? 'properties' : 'searches' + const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>( + window.location.hash === '#shared-links' ? 'shared-links' : 'searches' ); + const [shareLinks, setShareLinks] = useState([]); + const [shareLinksLoading, setShareLinksLoading] = useState(false); + const [shareLinksError, setShareLinksError] = useState(null); + + useEffect(() => { + let cancelled = false; + setShareLinksLoading(true); + setShareLinksError(null); + + fetch(apiUrl('share-links'), authHeaders()) + .then((res) => { + assertOk(res, 'Fetch share links'); + return res.json(); + }) + .then((data: { links: ShareLinkListItem[] }) => { + if (!cancelled) setShareLinks(data.links); + }) + .catch((err) => { + if (!cancelled) { + setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links'); + } + }) + .finally(() => { + if (!cancelled) setShareLinksLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); const tabClass = (tab: string) => `px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ @@ -512,11 +413,11 @@ export function SavedPage({ )} - @@ -532,12 +433,11 @@ export function SavedPage({ onOpen={onOpenSearch} /> ) : ( - )} @@ -552,6 +452,15 @@ interface InviteListItem { created: string; } +interface ShareLinkListItem { + code: string; + url: string; + og_image_url: string; + params: string; + click_count: number; + created: string; +} + function InviteTable({ invites, loading, @@ -646,7 +555,107 @@ function InviteTable({ ); } -export function InvitesPage({ user }: { user: AuthUser }) { +function ShareLinksSection({ + links, + loading, + error, + showTitle = true, +}: { + links: ShareLinkListItem[]; + loading: boolean; + error: string | null; + showTitle?: boolean; +}) { + const { t } = useTranslation(); + const [copiedCode, setCopiedCode] = useState(null); + + const handleCopy = (url: string, code: string) => { + copyToClipboard(url, () => { + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + }); + }; + + return ( +
+ {showTitle && ( +

+ {t('accountPage.shareLinksTitle')} +

+ )} +
+ {loading ? ( +
+ +
+ ) : error ? ( +

{error}

+ ) : links.length === 0 ? ( +

+ {t('accountPage.noShareLinksYet')} +

+ ) : ( +
+ {links.map((link) => ( +
+
+
+ +
+
+
+
+ + {link.url} + + +
+

+ {summarizeParams(link.params)} +

+

+ {formatRelativeTime(link.created)} +

+
+
+

+ {formatNumber(link.click_count)} +

+

+ {t('accountPage.clicksLabel')} +

+
+
+
+
+ ))} +
+ )} +
+
+ ); +} + +function InviteSection({ user }: { user: AuthUser }) { const { t } = useTranslation(); const [creatingInvite, setCreatingInvite] = useState>({}); const [inviteUrl, setInviteUrl] = useState>({}); @@ -720,15 +729,9 @@ export function InvitesPage({ user }: { user: AuthUser }) { if (!isLicensed) { return ( - -
-
-

- {t('invitesPage.inviteLinksLicensed')} -

-
-
-
+
+

{t('invitesPage.inviteLinksLicensed')}

+
); } @@ -736,89 +739,87 @@ export function InvitesPage({ user }: { user: AuthUser }) { const referralInvites = inviteHistory.filter((i) => i.invite_type === 'referral'); return ( - -
- {/* Generate invite links */} -
- {(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => ( -
-

- {type === 'admin' - ? t('invitesPage.inviteAdminLabel') - : t('invitesPage.inviteReferralLabel')} -

- {inviteUrl[type] ? ( -
- - -
- ) : ( +
+
+ {(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => ( +
+

+ {type === 'admin' + ? t('invitesPage.inviteAdminLabel') + : t('invitesPage.inviteReferralLabel')} +

+ {inviteUrl[type] ? ( +
+ - )} - {inviteError[type] && ( -

{inviteError[type]}

- )} -
- ))} -
+
+ ) : ( + + )} + {inviteError[type] && ( +

{inviteError[type]}

+ )} +
+ ))} +
- {/* Invite history tables */} - {user.isAdmin && ( - <> - - - - )} - {!user.isAdmin && referralInvites.length > 0 && ( + {user.isAdmin && ( + <> + - )} -
- + + )} + {!user.isAdmin && referralInvites.length > 0 && ( + + )} +
); } export default function AccountPage({ user, onRefreshAuth, + scrollTarget, }: { user: AuthUser; onRefreshAuth: () => Promise; + scrollTarget?: string; }) { const { t } = useTranslation(); const [newsletterSaving, setNewsletterSaving] = useState(false); @@ -831,6 +832,14 @@ export default function AccountPage({ ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' : 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300'; + useEffect(() => { + if (scrollTarget !== 'invites') return; + const frame = window.requestAnimationFrame(() => { + document.getElementById('invites')?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }); + return () => window.cancelAnimationFrame(frame); + }, [scrollTarget]); + return (
@@ -914,6 +923,13 @@ export default function AccountPage({
+
+

+ {t('header.inviteFriends')} +

+ +
+ {/* Support */}

{t('accountPage.needHelp')}

diff --git a/frontend/src/components/home/HomeFinalCta.tsx b/frontend/src/components/home/HomeFinalCta.tsx new file mode 100644 index 0000000..e114b10 --- /dev/null +++ b/frontend/src/components/home/HomeFinalCta.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import { trackEvent } from '../../lib/analytics'; + +const HOME_SECTION_HEADING_CLASS = + 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; +const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; +const HOME_PRIMARY_BUTTON_CLASS = + 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; + +export default function HomeFinalCta({ + onOpenDashboard, + trackingLocation = 'bottom', + className = '', +}: { + onOpenDashboard: () => void; + trackingLocation?: string; + className?: string; +}) { + const { t } = useTranslation(); + + return ( +
+

{t('home.ctaTitle')}

+

{t('home.ctaDescription')}

+ +
+ ); +} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 0585f77..8e929ff 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useRef } from 'react'; +import { lazy, Suspense, useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useFadeInRef } from '../../hooks/useFadeIn'; +import { useIsMobile } from '../../hooks/useIsMobile'; import HexCanvas from './HexCanvas'; -import ProductShowcase from './ProductShowcase'; +import HomeFinalCta from './HomeFinalCta'; import BottomIllustration from './BottomIllustration'; import { TickerValue } from '../ui/TickerValue'; import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons'; @@ -15,7 +16,7 @@ const HOME_SECTION_HEADING_CLASS = 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100'; const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400'; const HOME_PRIMARY_BUTTON_CLASS = - 'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center'; + 'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center'; const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record = { en: 'recording', de: 'recording-de', @@ -23,10 +24,24 @@ const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record = { hi: 'recording-hi', }; const PRODUCT_DEMO_SECTION_ID = 'product-demo-video'; +const ProductShowcase = lazy(() => import('./ProductShowcase')); -function getProductDemoSlug(language: string | undefined): string { +function ProductShowcaseFallback({ className = '' }: { className?: string }) { + return ( + - + }> + +
+
+
diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx index 71eacae..420eb5c 100644 --- a/frontend/src/components/home/ProductShowcase.tsx +++ b/frontend/src/components/home/ProductShowcase.tsx @@ -3,13 +3,14 @@ import { useEffect, useMemo, useRef, + lazy, + Suspense, type ComponentType, type MutableRefObject, } from 'react'; import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; import { cellToLatLng, polygonToCells } from 'h3-js'; -import ProductMap from '../map/Map'; import PriceHistoryChart from '../map/PriceHistoryChart'; import StackedBarChart from '../map/StackedBarChart'; import JourneyInstructions, { type JourneyInstructionPreset } from '../map/JourneyInstructions'; @@ -39,6 +40,7 @@ import type { } from '../../types'; const SHOWCASE_STEP_COUNT = 4; +const ProductMap = lazy(() => import('../map/Map')); const SHOWCASE_INTERVAL_MS = 5200; const SHOWCASE_SCOUT_INTERVAL_MS = 9000; const SHOWCASE_STEP_INTERVALS_MS = [ @@ -563,6 +565,7 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) { function EnglandHexMapScreen({ isActive }: { isActive: boolean }) { const { t } = useTranslation(); const [viewState, setViewState] = useState(SHOWCASE_MAP_START_VIEW); + const [shouldRenderMap, setShouldRenderMap] = useState(isActive); const elapsedRef = useRef(0); const lastFrameRef = useRef(null); @@ -570,6 +573,7 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) { elapsedRef.current = 0; lastFrameRef.current = null; setViewState(SHOWCASE_MAP_START_VIEW); + if (isActive) setShouldRenderMap(true); }, [isActive]); useEffect(() => { @@ -596,29 +600,33 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) { return (
- {}} - features={DEMO_FEATURES} - selectedHexagonId={null} - hoveredHexagonId={null} - onHexagonClick={noopHexagonClick} - onHexagonHover={noopHexagonHover} - initialViewState={viewState} - theme="dark" - screenshotMode - hideLegend - densityLabel={t('home.showcaseMatchingHomesLabel')} - totalCount={SHOWCASE_MAP_TOTAL_COUNT} - /> + {shouldRenderMap && ( + + )}
Birmingham
diff --git a/frontend/src/components/invite/InvitePage.tsx b/frontend/src/components/invite/InvitePage.tsx index 2666e8d..8cf5468 100644 --- a/frontend/src/components/invite/InvitePage.tsx +++ b/frontend/src/components/invite/InvitePage.tsx @@ -338,7 +338,7 @@ export default function InvitePage({
diff --git a/frontend/src/components/landing/SeoContentPage.tsx b/frontend/src/components/landing/SeoContentPage.tsx index c3dbf92..a326c45 100644 --- a/frontend/src/components/landing/SeoContentPage.tsx +++ b/frontend/src/components/landing/SeoContentPage.tsx @@ -1,21 +1,29 @@ +import { lazy, Suspense } from 'react'; import { useTranslation } from 'react-i18next'; import { CheckIcon } from '../ui/icons/CheckIcon'; +import HomeFinalCta from '../home/HomeFinalCta'; +import { usePageMeta } from '../../hooks/usePageMeta'; import { - SEO_CONTENT_PAGES, + getLocalizedSeoContentPage, type SeoContentKey, type SeoFaq, type SeoLink, type SeoSection, } from '../../lib/seoLandingPages'; +import { safeJsonLd } from '../../lib/json-ld'; const PUBLIC_URL = 'https://perfect-postcode.co.uk'; +const ProductShowcase = lazy(() => import('../home/ProductShowcase')); + +function ProductShowcaseFallback() { + return ( +