diff --git a/Gemfile b/Gemfile index b9985f66..9aa761bf 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem "terser" gem "httparty" gem "view_component" gem "seed-fu" +gem 'wicked' # for multi-step forms group :development do gem "better_errors" diff --git a/Gemfile.lock b/Gemfile.lock index c220c280..588aa252 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -436,6 +436,8 @@ GEM websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + wicked (2.0.0) + railties (>= 3.0.7) xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.37) @@ -498,6 +500,7 @@ DEPENDENCIES view_component web-console (>= 3.3.0) webmock + wicked RUBY VERSION ruby 3.3.6p108 diff --git a/app/assets/stylesheets/application.tailwind.css.scss b/app/assets/stylesheets/application.tailwind.css.scss index c700a73e..0cf54867 100644 --- a/app/assets/stylesheets/application.tailwind.css.scss +++ b/app/assets/stylesheets/application.tailwind.css.scss @@ -196,3 +196,73 @@ select { } } } + +form { + @apply mt-4; + + section { + @apply mb-4; + + > div { + @apply mb-2 block; + } + } + + label { + @apply block mb-1 font-semibold; + } + input[type="text"], + input[type="search"], + input[type="email"], + input[type="tel"], + input[type="password"], + textarea { + @apply w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500; + } + input[type="checkbox"], + input[type="radio"] { + @apply inline-block mr-2; + + + label { + @apply inline-block; + } + } + + .switch { + @apply relative inline-block w-11 h-5; + + input[type="checkbox"] { + @apply appearance-none w-11 h-5 bg-airtext-main-light rounded-full checked:bg-airtext-main cursor-pointer transition-colors duration-300; + } + label { + @apply absolute top-0 left-0 w-5 h-5 bg-white rounded-full border border-airtext-main shadow-sm transition-transform duration-300 cursor-pointer; + } + } + + .buttons { + @apply flex justify-between gap-2 mt-4; + + > * { + @apply w-1/2; + } + + a { + @apply inline-block px-4 py-2 font-semibold text-white bg-gray-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-1 focus:ring-airtext-main focus:border-airtext-main cursor-pointer text-center; + } + } + + button[type="submit"], input[type="submit"] { + @apply inline-block px-4 py-2 font-semibold text-white bg-airtext-main rounded-md hover:bg-blue-600 focus:outline-none focus:ring-1 focus:ring-airtext-main focus:border-airtext-main cursor-pointer; + } + .error-message { + @apply text-red-500 mt-1; + } +} + +#search-results { + @apply mt-4; + + li { + @apply mb-2 cursor-pointer; + } +} \ No newline at end of file diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb new file mode 100644 index 00000000..80cbbede --- /dev/null +++ b/app/controllers/subscriptions_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class SubscriptionsController < ApplicationController + include Wicked::Wizard + + steps :contact_details, :contact_verification, :zone_selection, :time_selection, :wrap_up + + def show + session[:subscription_form] = nil if params[:reset] + load + render_wizard + end + + def update + load + case step + when :contact_verification + # Do nothing + else + @form.assign_attributes(subscription_params) + end + session[:subscription_form] = @form.attributes + render_wizard @form + end + + private + + def load + @step_number = current_step_index.to_i + 1 + @current_step = step + @form = SubscriptionForm.new(session[:subscription_form]) + end + + def current_step_index + steps.index(step) + end + + def subscription_params + params[:subscription_form].permit! #.permit(:email, :sms_number, :voice_number, :zone, :time) + end +end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 461971b4..5d4737db 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -21,3 +21,6 @@ application.register("tab", TabController); import NavigationController from "./navigation_controller"; application.register("navigation", NavigationController); + +import SubscriptionController from "./subscription_controller"; +application.register("subscription", SubscriptionController); diff --git a/app/javascript/controllers/subscription_controller.js b/app/javascript/controllers/subscription_controller.js new file mode 100644 index 00000000..a8c03182 --- /dev/null +++ b/app/javascript/controllers/subscription_controller.js @@ -0,0 +1,110 @@ +import { Controller } from "@hotwired/stimulus"; +import { useDebounce } from "stimulus-use"; +import { geocoding } from "@maptiler/client"; +import * as zones from "../zone_boundaries/zone-boundaries"; +import booleanPointInPolygon from "@turf/boolean-point-in-polygon"; + +export default class SubscriptionController extends Controller { + static targets = ["email", "sms", "voice", "searchField", "searchResults"]; + static debounces = ["search"]; + + connect() { + useDebounce(this); + } + + toggleEmail() { + this.emailTarget.classList.toggle("hidden"); + } + + toggleSms() { + this.smsTarget.classList.toggle("hidden"); + } + + toggleVoice() { + this.voiceTarget.classList.toggle("hidden"); + } + + searchFieldTargetConnected() { + this.maptilerApiKey = this.searchFieldTarget.dataset.maptilerApiKey; + } + + async search() { + console.log("searching..."); + const userInput = this.searchFieldTarget.value; + const coordinates = await this.geoCode(userInput); + const zones = this.findZones(coordinates); + this.displaySearchResults(zones); + } + + async geoCode(location) { + console.log("geocoding..."); + const result = await geocoding.forward(location, { + apiKey: this.maptilerApiKey, + country: ["GB"], + proximity: [-0.116773, 51.510357], + types: [ + "region", + "subregion", + "county", + "joint_municipality", + "joint_submunicipality", + "municipality", + "municipal_district", + "locality", + "neighbourhood", + "place", + "postal_code", + "address", + "road", + "poi", + ], + }); + + return result?.features[0]?.geometry?.coordinates; + } + + findZones(point) { + console.log(point); + if (!point) { + console.log("No coordinates found"); + return; + } + + console.log("finding zone..."); + const matches = []; + // Point has to be in the format [longitude, latitude] + for (const [, zone] of Object.entries(zones)) { + if (booleanPointInPolygon(point, zone)) { + matches.push(zone); + } + } + + // Sort by zone level so more specific zones are returned first + matches.sort((a, b) => b.properties.level - a.properties.level); + + return matches; + } + + displaySearchResults(results) { + this.searchResultsTarget.innerHTML = ""; + + if (!results) { + this.searchResultsTarget.innerHTML = "No results found"; + return; + } + + results.forEach((result) => { + const li = document.createElement("li"); + li.textContent = result.properties.name; + li.dataset.action = "click->subscription#selectSearchResult"; + this.searchResultsTarget.appendChild(li); + }); + } + + selectSearchResult(event) { + const zoneName = event.target.textContent; + const checkbox = document.querySelector(`input[value="${zoneName}"]`); + console.log(checkbox) + checkbox.checked = true; + } +} diff --git a/app/models/subscription_form.rb b/app/models/subscription_form.rb new file mode 100644 index 00000000..12faef68 --- /dev/null +++ b/app/models/subscription_form.rb @@ -0,0 +1,120 @@ +class SubscriptionForm + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :current_step + + attribute :receive_email + attribute :receive_sms + attribute :receive_voice + attribute :email + attribute :sms_number + attribute :voice_number + + attribute :zone_search + attribute :zones + + attribute :time + + attribute :reason + attribute :source + attribute :research + attribute :terms + attribute :privacy + + validate :one_contact_method_present?, if: -> { current_step == "contact_details" } + validates :email, presence: true, if: -> { receive_email && current_step == "contact_details" } + validates :sms_number, presence: true, if: -> { receive_sms && current_step == "contact_details" } + validates :voice_number, presence: true, if: -> { receive_voice && current_step == "contact_details" } + + validate :no_more_than_5_zones?, if: -> { current_step == "zone_selection" } + validates :zones, presence: true, if: -> { current_step == "zone_selection" } + + validates :time, presence: true, if: -> { current_step == "time_selection" } + + validates :terms, acceptance: true, if: -> { current_step == "wrap_up" } + validates :privacy, acceptance: true, if: -> { current_step == "wrap_up" } + + TIMES = [ + ['AM', 'Morning ā€“ 6am'], + ['PM', 'Evening ā€“ 6pm'], + ] + + SOURCES = [ + [:airtext_application_form, 'airTEXT application form'], + [:poster_on_bus, 'Poster on bus'], + [:poster_on_tube, 'Poster on tube'], + [:mayor_of_london, 'Mayor of London'], + [:tfl_air_quality_alerts, 'TfL air quality alerts'], + [:council_website, 'Council website'], + [:council_magazine, 'Council magazine'], + [:breathe_easy_group, 'Breathe Easy group'], + [:barts_health_nhs_trust, 'Barts Health NHS trust'], + [:islington_primary_schools, 'Islington primary schools'], + [:shine, 'SHINE (Seasonal Health Interventions Network)'], + [:merton_cac, "Merton's Clean Air Campaign"], + [:be_air_aware_campaign, 'Be Air Aware Campaign'], + [:my_doctor, 'My doctor'], + [:local_pharmacist, 'Local pharmacist'], + [:local_council_employee, 'Local council employee'], + [:national_newspaper, 'National newspaper'], + [:local_newspaper, 'Local newspaper'], + [:air_quality_workshop, 'Air Quality Workshop'], + [:a_friend, 'A friend'], + [:tv_radio, 'TV/radio'], + [:web_or_search_engine, 'Web or Search Engine'], + [:twitter, 'Twitter'], + [:facebook, 'Facebook'], + [:other, 'Other'], + ] + + def one_contact_method_present? + errors.add(:base, 'You must select at least one contact method') if email.blank? && sms_number.blank? && voice_number.blank? + end + + def no_more_than_5_zones? + return false unless zones.present? + + errors.add(:zones, 'You can select no more than 5 zones') if zones.size > 5 + end + + def save + if current_step == "wrap_up" && valid? + create_subscriptions + else + valid? + end + end + + def modes + [ + ("email" if receive_email), + ("sms" if receive_sms), + ("voicemail" if receive_voice), + ].compact + end + + def create_subscriptions + zones.compact.each do |zone| + modes.each do |mode| + create_subscription(zone, mode) + end + end + end + + def create_subscription(zone, mode) + CercSubscriberApiClient.create_subscription( + # subscriber_id: subscriber_id, + zone: zone, + mode: mode, + phone: (sms_number if mode == "sms") || (voice_number if mode == "voice"), + email: (email if mode == "email"), + ampm: time, + subscriber_details: { + "whySignup" => reason, + "howHeard" => source, + "allowContact" => research, + } + ) + end +end diff --git a/app/views/forecasts/_subscribe.html.erb b/app/views/forecasts/_subscribe.html.erb index 791fde64..a2c92ef8 100644 --- a/app/views/forecasts/_subscribe.html.erb +++ b/app/views/forecasts/_subscribe.html.erb @@ -2,7 +2,5 @@
Subscribe to receive air quality alerts for your area
- - Sign up for alerts - + <%= link_to("Sign up for alerts", subscriptions_path, class: "button") %> diff --git a/app/views/subscriptions/_current.html.erb b/app/views/subscriptions/_current.html.erb new file mode 100644 index 00000000..94b41741 --- /dev/null +++ b/app/views/subscriptions/_current.html.erb @@ -0,0 +1,17 @@ +
+
+<%= JSON.pretty_generate(@form.as_json["attributes"]) %>
+
+
+ +Step <%= @step_number %> of 5 + +<% if @form.errors.any? %> +
+ +
+<% end %> \ No newline at end of file diff --git a/app/views/subscriptions/contact_details.html.erb b/app/views/subscriptions/contact_details.html.erb new file mode 100644 index 00000000..614a103e --- /dev/null +++ b/app/views/subscriptions/contact_details.html.erb @@ -0,0 +1,47 @@ +<%= render 'current', form: @form %> + +<%= form_for @form, url: wizard_path, method: :put, data: { controller: "subscription" } do |f| %> + <%= f.hidden_field :current_step, value: @current_step %> + +

Choose the channels you would like to receive alerts on:

+
+ <%= f.label :receive_email, "Receive email alerts", class: "relative bottom-[2px]" %> +
+ <%= f.check_box :receive_email, include_hidden: false, class:"peer", data: { action: "subscription#toggleEmail" } %> + <%= f.label :receive_email, "​".html_safe, class: "peer-checked:translate-x-6 peer-checked:border-airtext-main" %> +
+
+
"> + <%= f.label :email, class: "hidden" %> + <%= f.email_field :email, placeholder: "Enter your email address" %> +
+ +
+ <%= f.label :receive_sms, "Receive text alerts", class: "relative bottom-[2px]" %> +
+ <%= f.check_box :receive_sms, include_hidden: false, class:"peer", data: { action: "subscription#toggleSms" } %> + <%= f.label :receive_sms, "​".html_safe, class: "peer-checked:translate-x-6 peer-checked:border-airtext-main" %> +
+
+
"> + <%= f.label :sms_number, class: "hidden" %> + <%= f.phone_field :sms_number, placeholder: "Enter your mobile number" %> +
+ +
+ <%= f.label :receive_voice, "Receive voicemail alerts", class: "relative bottom-[2px]" %> +
+ <%= f.check_box :receive_voice, include_hidden: false, class:"peer", data: { action: "subscription#toggleVoice" } %> + <%= f.label :receive_voice, "​".html_safe, class: "peer-checked:translate-x-6 peer-checked:border-airtext-main" %> +
+
+
"> + <%= f.label :voice_number, class: "hidden" %> + <%= f.phone_field :voice_number, placeholder: "Enter your phone number" %> +
+ +
+ <%= link_to("Back", :back) %> + <%= f.submit "Next" %> +
+<% end %> diff --git a/app/views/subscriptions/contact_verification.html.erb b/app/views/subscriptions/contact_verification.html.erb new file mode 100644 index 00000000..2182ab16 --- /dev/null +++ b/app/views/subscriptions/contact_verification.html.erb @@ -0,0 +1,12 @@ +<%= render 'current', form: @form %> + +<%= form_for @form, url: wizard_path, method: :put, data: { controller: "subscription" } do |f| %> + <%= f.hidden_field :current_step, value: @current_step %> + +

Your email is verified, and you can continue the subscription process with this address: <%= @form.email %>

+ +
+ <%= link_to("Back", previous_wizard_path) %> + <%= f.submit "Next" %> +
+<% end %> diff --git a/app/views/subscriptions/time_selection.html.erb b/app/views/subscriptions/time_selection.html.erb new file mode 100644 index 00000000..ccd8f223 --- /dev/null +++ b/app/views/subscriptions/time_selection.html.erb @@ -0,0 +1,20 @@ +<%= render 'current', form: @form %> + +<%= form_for @form, url: wizard_path, method: :put, data: { controller: "subscription" } do |f| %> + <%= f.hidden_field :current_step, value: @current_step %> + +
+ <%= f.label :time, "We issue alerts twice a day, select your preferred time to receive them." %> + <%= f.collection_radio_buttons :time, SubscriptionForm::TIMES, :first, :last, prompt: "Select a time" do |b| %> +
+ <%= b.radio_button %> + <%= b.label %> +
+ <% end %> +
+ +
+ <%= link_to("Back", previous_wizard_path) %> + <%= f.submit "Next" %> +
+<% end %> diff --git a/app/views/subscriptions/wrap_up.html.erb b/app/views/subscriptions/wrap_up.html.erb new file mode 100644 index 00000000..14edd730 --- /dev/null +++ b/app/views/subscriptions/wrap_up.html.erb @@ -0,0 +1,40 @@ +<%= render 'current', form: @form %> + +<%= form_for @form, url: wizard_path, method: :put, data: { controller: "subscription" } do |f| %> + <%= f.hidden_field :current_step, value: @current_step %> + +
+ <%= f.label :reason, "Why are you signing up for airTEXT alerts?" %> + <%= f.text_area :reason, placeholder: "Please tell us why you are interested in receiving air quality alerts" %> +
+ +
+ <%= f.label :source %> + <%= f.collection_select :source, SubscriptionForm::SOURCES, :first, :second, prompt: "How did you hear about airTEXT?" %> +
+ +
+ <%= f.label :research, "May we contact you in the future for research purposes?" %> + <%= f.collection_radio_buttons :research, [["Yes, Iā€™m ok to collaborate", true], ["No, thank you", false]], :last, :first do |b| %> +
+ <%= b.radio_button %> + <%= b.label %> +
+ <% end %> +
+ +
+ <%= f.check_box :terms %> + <%= f.label :terms, 'I have read and agree to the terms and conditions of the airTEXT service'.html_safe %> +
+ +
+ <%= f.check_box :privacy %> + <%= f.label :privacy, 'I have read and agree to the privacy policy of the airTEXT service'.html_safe %> +
+ +
+ <%= link_to("Back", previous_wizard_path) %> + <%= f.submit "Submit" %> +
+<% end %> diff --git a/app/views/subscriptions/zone_selection.html.erb b/app/views/subscriptions/zone_selection.html.erb new file mode 100644 index 00000000..5c48fb1b --- /dev/null +++ b/app/views/subscriptions/zone_selection.html.erb @@ -0,0 +1,33 @@ +<%= render 'current', form: @form %> + +<%= form_for @form, url: wizard_path, method: :put, data: { controller: "subscription" } do |f| %> + <%= f.hidden_field :current_step, value: @current_step %> + +
+ <%= f.label :zone_search, "Select locations to receive alert for" %> + <%= f.text_field :zone_search, + placeholder: "Enter a place, address or postcode", + type: "search", + data: { + action: "subscription#search", + "subscription-target": "searchField", + "maptiler-api-key": ENV["MAPTILER_API_KEY"] + } %> +
+
+ +
+ <%= f.label :zones, class: "hidden" %> + <%= f.collection_check_boxes :zones, Zone.all, :name, :name, { include_hidden: false, multiple: true } do |b| %> +
+ <%= b.check_box %> + <%= b.label %> +
+ <% end %> +
+ +
+ <%= link_to("Back", previous_wizard_path) %> + <%= f.submit "Next" %> +
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 1b61e190..b17fa92c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,7 @@ get "map", to: "map#show" get :forecast, to: "forecasts#show" + resources :subscriptions get :health_advice, to: "pages#health_advice" get :about, to: "pages#about" diff --git a/package.json b/package.json index 1eff12d6..0142225c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.11.1", - "@maptiler/leaflet-maptilersdk": "^2.0.0", "esbuild": "^0.24.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.0.0", @@ -27,14 +26,17 @@ "stylelint-config-standard-scss": "^13.1.0" }, "engines": { - "node": "22.11.0", + "node": "22.8.0", "yarn": "1.22.22" }, "dependencies": { "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo-rails": "^8.0.10", "@maptiler/geocoding-control": "^1.4.1", + "@maptiler/leaflet-maptilersdk": "^2.0.0", + "@turf/boolean-point-in-polygon": "^7.1.0", "depcheck": "^1.4.7", + "stimulus-use": "^0.52.2", "tailwindcss": "^3.4.13" } } diff --git a/tailwind.config.js b/tailwind.config.js index c46cc364..d64f5538 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,6 +9,8 @@ module.exports = { theme: { extend: { colors: { + 'airtext-main': '#3880D1', + 'airtext-main-light': '#DAE7F7', 'moderate-alert-guidance-panel': '#FBDA3033', 'high-alert-guidance-panel': '#EBDBDE', 'very-high-alert-guidance-panel': '#EBDBDE', diff --git a/yarn.lock b/yarn.lock index 1d47ce67..ed8c2781 100644 --- a/yarn.lock +++ b/yarn.lock @@ -521,6 +521,17 @@ "@types/geojson" "^7946.0.10" tslib "^2.6.2" +"@turf/boolean-point-in-polygon@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.1.0.tgz#dec07b467d74b4409eb03acc60b874958f71b49a" + integrity sha512-mprVsyIQ+ijWTZwbnO4Jhxu94ZW2M2CheqLiRTsGJy0Ooay9v6Av5/Nl3/Gst7ZVXxPqMeMaFYkSzcTc87AKew== + dependencies: + "@turf/helpers" "^7.1.0" + "@turf/invariant" "^7.1.0" + "@types/geojson" "^7946.0.10" + point-in-polygon-hao "^1.1.0" + tslib "^2.6.2" + "@turf/clone@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-7.1.0.tgz#b0cbf60b84fadd30ae8411f12d3bdcd3e773577f" @@ -559,6 +570,15 @@ "@types/geojson" "^7946.0.10" tslib "^2.6.2" +"@turf/invariant@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.1.0.tgz#c90cffa093291316b597212396d68bf9e465cf2e" + integrity sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q== + dependencies: + "@turf/helpers" "^7.1.0" + "@types/geojson" "^7946.0.10" + tslib "^2.6.2" + "@turf/meta@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.1.0.tgz#b2af85afddd0ef08aeae8694a12370a4f06b6d13" @@ -2165,6 +2185,11 @@ please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" +point-in-polygon-hao@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz#37f5f4fbe14e89fa8a3bb7f67c9158079d2ede7c" + integrity sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ== + polygon-clipping@^0.15.3: version "0.15.7" resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.15.7.tgz#3823ca1e372566f350795ce9dd9a7b19e97bdaad" @@ -2490,6 +2515,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +stimulus-use@^0.52.2: + version "0.52.2" + resolved "https://registry.yarnpkg.com/stimulus-use/-/stimulus-use-0.52.2.tgz#fc992fababe03f8d8bc2d9470c8cdb40bd075917" + integrity sha512-413+tIw9n6Jnb0OFiQE1i3aP01i0hhGgAnPp1P6cNuBbhhqG2IOp8t1O/4s5Tw2lTvSYrFeLNdaY8sYlDaULeg== + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"