diff --git a/app/components/spinner_button_component.html.erb b/app/components/spinner_button_component.html.erb
index 8797f91489e..d8be4e272b3 100644
--- a/app/components/spinner_button_component.html.erb
+++ b/app/components/spinner_button_component.html.erb
@@ -1,4 +1,4 @@
-<%= content_tag(:'lg-spinner-button', class: css_class) do %>
+<%= content_tag(:'lg-spinner-button', class: css_class, 'spin-on-click': spin_on_click) do %>
<%= render ButtonComponent.new(type: :button, **button_options).with_content(content) %>
diff --git a/app/components/spinner_button_component.rb b/app/components/spinner_button_component.rb
index 1ef36d15fca..e326ab74e72 100644
--- a/app/components/spinner_button_component.rb
+++ b/app/components/spinner_button_component.rb
@@ -1,13 +1,14 @@
class SpinnerButtonComponent < BaseComponent
- attr_reader :action_message, :button_options, :outline
+ attr_reader :action_message, :button_options, :outline, :spin_on_click
# @param [String] action_message Message describing the action being performed, shown visually to
# users when the animation has been active for a long time, and
# immediately to users of assistive technology.
- def initialize(action_message: nil, **button_options)
+ def initialize(action_message: nil, spin_on_click: nil, **button_options)
@action_message = action_message
@button_options = button_options
@outline = button_options[:outline]
+ @spin_on_click = spin_on_click
end
def css_class
diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb
index 5a7a5bf8163..707308d76ef 100644
--- a/app/controllers/frontend_log_controller.rb
+++ b/app/controllers/frontend_log_controller.rb
@@ -9,6 +9,7 @@ class FrontendLogController < ApplicationController
'IdV: verify in person troubleshooting option clicked' => :idv_verify_in_person_troubleshooting_option_clicked,
'IdV: location visited' => :idv_in_person_location_visited,
'IdV: location submitted' => :idv_in_person_location_submitted,
+ 'IdV: Mobile device and camera check' => :idv_mobile_device_and_camera_check,
'IdV: prepare visited' => :idv_in_person_prepare_visited,
'IdV: prepare submitted' => :idv_in_person_prepare_submitted,
'IdV: switch_back visited' => :idv_in_person_switch_back_visited,
diff --git a/app/javascript/packs/document-capture-welcome.js b/app/javascript/packs/document-capture-welcome.js
deleted file mode 100644
index a928f639f7b..00000000000
--- a/app/javascript/packs/document-capture-welcome.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { hasCamera, isCameraCapableMobile } from '@18f/identity-device';
-
-const form = document.querySelector('.js-consent-continue-form');
-
-if (form && isCameraCapableMobile()) {
- (async () => {
- if (!(await hasCamera())) {
- const ncInput = document.createElement('input');
- ncInput.type = 'hidden';
- ncInput.name = 'no_camera';
- form.appendChild(ncInput);
- }
- })();
-
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = 'skip_upload';
- form.appendChild(input);
-}
diff --git a/app/javascript/packs/document-capture-welcome.ts b/app/javascript/packs/document-capture-welcome.ts
new file mode 100644
index 00000000000..0dba2fd8914
--- /dev/null
+++ b/app/javascript/packs/document-capture-welcome.ts
@@ -0,0 +1,73 @@
+import { trackEvent } from '@18f/identity-analytics';
+import { hasCamera, isCameraCapableMobile } from '@18f/identity-device';
+
+const GRACE_TIME_FOR_CAMERA_CHECK_MS = 2000;
+const DEVICE_CHECK_EVENT = 'IdV: Mobile device and camera check';
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function measure(func: () => Promise): Promise<{ result: T; duration: number }> {
+ const start = performance.now();
+ const result = await func();
+ const duration = performance.now() - start;
+ return { result, duration };
+}
+
+function addFormInputsForMobileDeviceCapabilities() {
+ const form = document.querySelector('.js-consent-continue-form');
+
+ if (!form) {
+ return;
+ }
+
+ if (!isCameraCapableMobile()) {
+ trackEvent(DEVICE_CHECK_EVENT, {
+ is_camera_capable_mobile: false,
+ });
+ return;
+ }
+
+ // The check for a camera on the device is async -- kick it off here and intercept
+ // submit() to ensure that it completes in time.
+ const cameraCheckPromise = measure(hasCamera).then(
+ async ({ result: cameraPresent, duration }) => {
+ if (!cameraPresent) {
+ // Signal to the backend that this is a mobile device, but no camera is present
+ const ncInput = document.createElement('input');
+ ncInput.type = 'hidden';
+ ncInput.name = 'no_camera';
+ form.appendChild(ncInput);
+ }
+
+ // Signal to the backend that this is a mobile device, and this user should skip the
+ // "hybrid handoff" step.
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'skip_upload';
+ form.appendChild(input);
+
+ await trackEvent(DEVICE_CHECK_EVENT, {
+ is_camera_capable_mobile: true,
+ camera_present: !!cameraPresent,
+ grace_time: GRACE_TIME_FOR_CAMERA_CHECK_MS,
+ duration: Math.floor(duration),
+ });
+ },
+ );
+
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+
+ for (const spinner of form.querySelectorAll('lg-spinner-button')) {
+ spinner.toggleSpinner(true);
+ }
+
+ Promise.race([delay(GRACE_TIME_FOR_CAMERA_CHECK_MS), cameraCheckPromise]).then(() =>
+ form.submit(),
+ );
+ });
+}
+
+addFormInputsForMobileDeviceCapabilities();
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index ffd637f9fbf..dfba92aecc5 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -3089,6 +3089,28 @@ def idv_arcgis_request_failure(
)
end
+ # Tracks whether the user's device appears to be mobile device with a camera attached.
+ # @param [Boolean] is_camera_capable_mobile Whether we think the device _could_ have a camera.
+ # @param [Boolean,nil] camera_present Whether the user's device _actually_ has a camera available.
+ # @param [Integer,nil] grace_time Extra time allowed for browser to report camera availability.
+ # @param [Integer,nil] duration Time taken for browser to report camera availability.
+ def idv_mobile_device_and_camera_check(
+ is_camera_capable_mobile:,
+ camera_present: nil,
+ grace_time: nil,
+ duration: nil,
+ **extra
+ )
+ track_event(
+ 'IdV: Mobile device and camera check',
+ is_camera_capable_mobile: is_camera_capable_mobile,
+ camera_present: camera_present,
+ grace_time: grace_time,
+ duration: duration,
+ **extra,
+ )
+ end
+
# Tracks when the user visits one of the the session error pages.
# @param [String] type
# @param [Integer,nil] attempts_remaining
diff --git a/app/views/idv/doc_auth/agreement.html.erb b/app/views/idv/doc_auth/agreement.html.erb
index 1c33d030ced..6c952c8d601 100644
--- a/app/views/idv/doc_auth/agreement.html.erb
+++ b/app/views/idv/doc_auth/agreement.html.erb
@@ -36,7 +36,16 @@
MarketingSite.security_and_privacy_practices_url,
) %>
- <%= f.submit t('doc_auth.buttons.continue'), class: 'margin-top-4' %>
+
+ <%= render(
+ SpinnerButtonComponent.new(
+ type: :submit,
+ big: true,
+ wide: true,
+ spin_on_click: false,
+ ).with_content(t('doc_auth.buttons.continue')),
+ ) %>
+
<% end %>
<%= render 'idv/doc_auth/cancel', step: 'agreement' %>
diff --git a/app/views/idv/doc_auth/welcome.html.erb b/app/views/idv/doc_auth/welcome.html.erb
index 9bfe4019ea5..e3763d0cfc9 100644
--- a/app/views/idv/doc_auth/welcome.html.erb
+++ b/app/views/idv/doc_auth/welcome.html.erb
@@ -52,7 +52,13 @@
url: url_for,
method: 'put',
html: { autocomplete: 'off', class: 'margin-y-5 js-consent-continue-form' } do |f| %>
- <%= f.submit t('doc_auth.buttons.continue') %>
+ <%= render(
+ SpinnerButtonComponent.new(
+ type: :submit,
+ big: true,
+ wide: true,
+ ).with_content(t('doc_auth.buttons.continue')),
+ ) %>
<% end %>
<%= render(
diff --git a/spec/components/spinner_button_component_spec.rb b/spec/components/spinner_button_component_spec.rb
index 114fdad7b10..3b468732aca 100644
--- a/spec/components/spinner_button_component_spec.rb
+++ b/spec/components/spinner_button_component_spec.rb
@@ -17,6 +17,11 @@
expect(rendered).to_not have_selector('.spinner-button__action-message')
end
+ it 'renders without spin-on-click attribute by default' do
+ render_inline SpinnerButtonComponent.new.with_content('hi')
+ expect(page).to_not have_selector('[spin-on-click]')
+ end
+
context 'with action message' do
it 'renders with action message' do
rendered = render_inline SpinnerButtonComponent.new(
@@ -34,4 +39,11 @@
expect(rendered).to have_css('lg-spinner-button.spinner-button--outline')
end
end
+
+ context 'with spin_on_click' do
+ it 'renders spin-on-click attribute' do
+ render_inline SpinnerButtonComponent.new(spin_on_click: true).with_content('')
+ expect(page).to have_selector('[spin-on-click=true]')
+ end
+ end
end