From 3e456b98aa5c82cbf857364f8de728e41e99d322 Mon Sep 17 00:00:00 2001 From: nora <163450896+24c02@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:20:18 -0500 Subject: [PATCH] Address autocomplete! (#115) * first shot * it works! * fix addr portal start action * fix add address button * pass in country * that should do it! * wew! * lint --- .erb_lint.yml | 2 + app/controllers/concerns/portal_flow.rb | 4 +- .../portal/verifications_controller.rb | 3 + .../entrypoints/address-autocomplete.js | 171 ++++++++++++++++++ app/frontend/entrypoints/application.js | 1 + .../stylesheets/snippets/addresses.scss | 21 +++ app/helpers/application_helper.rb | 7 + app/models/program.rb | 4 +- app/views/addresses/_address_list.html.erb | 5 +- app/views/addresses/_form.html.erb | 33 ++-- app/views/portal/addresses/portal.html.erb | 1 + config/credentials.yml.enc | 2 +- config/credentials/production.yml.enc | 2 +- config/locales/en.yml | 9 +- 14 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 app/frontend/entrypoints/address-autocomplete.js diff --git a/.erb_lint.yml b/.erb_lint.yml index 3cc29bf..b0ade82 100644 --- a/.erb_lint.yml +++ b/.erb_lint.yml @@ -1,5 +1,7 @@ --- EnableDefaultLinters: true +exclude: + - 'app/views/addresses/_address_list.html.erb' linters: AllowedScriptType: enabled: no diff --git a/app/controllers/concerns/portal_flow.rb b/app/controllers/concerns/portal_flow.rb index 676da6c..9f4004f 100644 --- a/app/controllers/concerns/portal_flow.rb +++ b/app/controllers/concerns/portal_flow.rb @@ -2,8 +2,8 @@ module PortalFlow extend ActiveSupport::Concern included do - before_action :validate_portal_return_url, only: [ :start, :portal ] - before_action :store_return_url, only: [ :start, :portal ] + before_action :validate_portal_return_url, only: [ :portal ] + before_action :store_return_url, only: [ :portal ] helper_method :portal_onboarding_scenario end diff --git a/app/controllers/portal/verifications_controller.rb b/app/controllers/portal/verifications_controller.rb index d7c35a3..678dd7c 100644 --- a/app/controllers/portal/verifications_controller.rb +++ b/app/controllers/portal/verifications_controller.rb @@ -1,6 +1,9 @@ class Portal::VerificationsController < Portal::BaseController include VerificationFlow + before_action :validate_portal_return_url, only: [ :start ] + before_action :store_return_url, only: [ :start ] + def start @identity = current_identity status = @identity.verification_status diff --git a/app/frontend/entrypoints/address-autocomplete.js b/app/frontend/entrypoints/address-autocomplete.js new file mode 100644 index 0000000..43acb45 --- /dev/null +++ b/app/frontend/entrypoints/address-autocomplete.js @@ -0,0 +1,171 @@ +// Google Maps callback - fired when the API is fully loaded +window.onGoogleMapsLoaded = function() { + window.googleMapsReady = true + window.dispatchEvent(new CustomEvent('google-maps-loaded')) +} + +// Override attachShadow to inject custom styles into gmp-place-autocomplete +const originalAttachShadow = Element.prototype.attachShadow; +Element.prototype.attachShadow = function(init) { + if (this.localName === 'gmp-place-autocomplete') { + const shadow = originalAttachShadow.call(this, { ...init, mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = ` + :host { + background: var(--theme-background-input); + border: none; + box-shadow: inset 0 0 0 1px var(--theme-border); + color: var(--theme-text); + display: block; + } + :host:focus-within { + outline: none; + box-shadow: inset 0 0 0 2px var(--theme-focused-foreground); + } + .widget-container { + border: none !important; + background: transparent !important; + padding: 0 !important; + } + .input-container { + padding: 0 !important; + background: transparent !important; + } + .focus-ring { + display: none !important; + } + input { + background: transparent !important; + border: none !important; + box-shadow: none !important; + color: var(--theme-text); + font-family: var(--font-family-mono); + font-size: var(--font-size); + line-height: calc(var(--theme-line-height-base) * 1rem); + padding: 0 1ch; + } + input::placeholder { + color: var(--theme-border); + } + `; + shadow.appendChild(style); + return shadow; + } + return originalAttachShadow.call(this, init); +}; + +function createAddressAutocomplete() { + return { + callingCodes: {}, + callingCode: '1', + selectedCountry: 'US', + + init() { + if (window.googleMapsReady) { + this.initAutocomplete() + } else { + window.addEventListener('google-maps-loaded', () => this.initAutocomplete(), { once: true }) + } + }, + + updateCallingCode(countrySelect) { + const code = this.callingCodes[countrySelect.value]; + if (code) this.callingCode = code; + this.updateAutocompleteCountry(countrySelect.value); + }, + + updateAutocompleteCountry(country) { + this.selectedCountry = country; + if (country && this.$refs.autocomplete) { + this.$refs.autocomplete.includedRegionCodes = [country]; + } + }, + + async initAutocomplete() { + await customElements.whenDefined('gmp-place-autocomplete') + + const autocomplete = this.$refs.autocomplete + if (!autocomplete) return + + autocomplete.addEventListener('gmp-select', async ({ placePrediction }) => { + await this.fillAddress(placePrediction) + }) + + // Wait for shadow DOM input to be created + const input = await this.waitForInput(autocomplete) + if (input) { + input.placeholder = 'Address line 1' + input.focus() + } + }, + + waitForInput(autocomplete) { + return new Promise(resolve => { + const check = () => { + const input = autocomplete.shadowRoot?.querySelector('input') + if (input) { + resolve(input) + } else { + requestAnimationFrame(check) + } + } + check() + }) + }, + + async fillAddress(placePrediction) { + const place = placePrediction.toPlace() + await place.fetchFields({ fields: ['addressComponents'] }) + + if (!place.addressComponents) return + + let streetNumber = '' + let route = '' + let postalCode = '' + + for (const component of place.addressComponents) { + const types = component.types + + if (types.includes('street_number')) { + streetNumber = component.longText + } + if (types.includes('route')) { + route = component.shortText + } + if (types.includes('postal_code')) { + postalCode = component.longText + } + if (types.includes('postal_code_suffix')) { + postalCode = `${postalCode}-${component.longText}` + } + if (types.includes('locality') || types.includes('sublocality_level_1') || types.includes('postal_town')) { + if (this.$refs.city) this.$refs.city.value = component.longText + } + if (types.includes('administrative_area_level_1')) { + if (this.$refs.state) this.$refs.state.value = component.shortText + } + if (types.includes('country')) { + if (this.$refs.country) { + this.$refs.country.value = component.shortText + this.$refs.country.dispatchEvent(new Event('change', { bubbles: true })) + } + } + } + + const line1 = [streetNumber, route].filter(Boolean).join(' ') + if (this.$refs.line1) this.$refs.line1.value = line1 + if (this.$refs.postalCode && postalCode) this.$refs.postalCode.value = postalCode + + if (this.$refs.line2) this.$refs.line2.focus() + } + } +} + +document.addEventListener('alpine:init', () => { + Alpine.data('addressAutocomplete', createAddressAutocomplete) +}) + +if (window.Alpine) { + window.Alpine.data('addressAutocomplete', createAddressAutocomplete) +} diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index c415eff..3d8fa58 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -2,6 +2,7 @@ import "../js/alpine.js"; import "../js/lightswitch.js"; import "../js/click-to-copy"; import "../js/otp-input.js"; + import htmx from "htmx.org" window.htmx = htmx diff --git a/app/frontend/stylesheets/snippets/addresses.scss b/app/frontend/stylesheets/snippets/addresses.scss index c6b9062..3b87652 100644 --- a/app/frontend/stylesheets/snippets/addresses.scss +++ b/app/frontend/stylesheets/snippets/addresses.scss @@ -169,3 +169,24 @@ button, input[type="submit"] { font-weight: 500; border-radius: $radius-md; } } } + +// Google Places Autocomplete styling +gmp-place-autocomplete { + display: block; + width: 100%; + margin-bottom: $space-3; + background-color: var(--pico-form-element-background-color); + border: 1px solid var(--pico-form-element-border-color); + border-radius: 10px; + color-scheme: light; +} + +[data-theme="dark"] gmp-place-autocomplete { + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) gmp-place-autocomplete { + color-scheme: dark; + } +} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0f44dfc..b61eaba 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -61,4 +61,11 @@ module ApplicationHelper def emoji_image(name, alt: name) vite_image_tag("images/emoji/#{name}", alt: alt, style: "height: 1em; vertical-align: baseline;") end + + def google_places_api_script_tag + api_key = Rails.application.credentials.dig(:google, :places_api_key) + return unless api_key.present? + + tag.script(src: "https://maps.googleapis.com/maps/api/js?key=#{api_key}&loading=async&libraries=places&callback=onGoogleMapsLoaded", async: true, defer: true) + end end diff --git a/app/models/program.rb b/app/models/program.rb index 1d6ed1d..5ed85f5 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -116,9 +116,9 @@ class Program < ApplicationRecord end end end - + # Prefer programs with onboarding scenarios set - return matching_programs.find { |p| p.onboarding_scenario.present? } || matching_programs.first + matching_programs.find { |p| p.onboarding_scenario.present? } || matching_programs.first rescue URI::InvalidURIError nil end diff --git a/app/views/addresses/_address_list.html.erb b/app/views/addresses/_address_list.html.erb index 1956442..149c43a 100644 --- a/app/views/addresses/_address_list.html.erb +++ b/app/views/addresses/_address_list.html.erb @@ -77,8 +77,9 @@