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 @@
+<%= JSON.pretty_generate(@form.as_json["attributes"]) %>
+
+
Choose the channels you would like to receive alerts on:
+Your email is verified, and you can continue the subscription process with this address: <%= @form.email %>
+ + +<% 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 %> + +