diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ee9aa3037a..0a14d738825 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -105,7 +105,7 @@ install: specs: stage: test - parallel: 7 + parallel: 11 cache: - <<: *ruby_cache - <<: *yarn_cache diff --git a/app/assets/images/email/README.md b/app/assets/images/email/README.md new file mode 100644 index 00000000000..9796d3c69ac --- /dev/null +++ b/app/assets/images/email/README.md @@ -0,0 +1,3 @@ +# Email Images + +This folder contains images for exclusive use by mailer templates. This includes email-specific imagery, and also variants of existing assets. For example, since [SVG images are not well-supported](https://www.caniemail.com/features/image-svg/) in all email clients, this folder may include rasterized versions of common SVG images. diff --git a/app/assets/images/email/info.png b/app/assets/images/email/info.png new file mode 100644 index 00000000000..67da418b193 Binary files /dev/null and b/app/assets/images/email/info.png differ diff --git a/app/assets/stylesheets/components/_barcode.scss b/app/assets/stylesheets/components/_barcode.scss new file mode 100644 index 00000000000..e842cb94627 --- /dev/null +++ b/app/assets/stylesheets/components/_barcode.scss @@ -0,0 +1,14 @@ +.barcode.barby-barcode { + width: auto; + table-layout: fixed; + border-spacing: 0; +} + +.barcode .barby-cell { + width: 2px; + height: 96px; + + &.on { + background-color: #000; + } +} diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index ee295a189d1..3db8dedb6fb 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -1,5 +1,6 @@ @import 'account-header'; @import 'banner'; +@import 'barcode'; @import 'block-link'; @import 'block-submit-button'; @import 'btn'; diff --git a/app/assets/stylesheets/email.css.scss b/app/assets/stylesheets/email.css.scss index f1ae38d61e7..0c13b1ef748 100644 --- a/app/assets/stylesheets/email.css.scss +++ b/app/assets/stylesheets/email.css.scss @@ -1,5 +1,9 @@ +@import 'required'; @import 'variables/email'; @import 'foundation-emails/scss/foundation-emails'; +@import 'identity-style-guide/dist/assets/scss/packages/required'; +@import 'identity-style-guide/dist/assets/scss/packages/utilities'; +@import './components/barcode'; .gray { &:active, @@ -118,3 +122,37 @@ h4 { padding-right: $global-gutter-small !important; } } + +.info-alert { + background-color: color('info-lighter'); + padding: 0 units(0.5); + + td { + padding: units(1.5); + padding-right: units(1); + + & + td { + padding-left: 0; + padding-right: units(1.5); + } + } +} + +.process-list td { + padding-bottom: units(4); +} + +.process-list__circle { + border-radius: 50%; + width: units(3); + height: units(2.5); + background-color: color($theme-process-list-counter-background-color); + border: units($theme-process-list-counter-border-width) solid + color($theme-process-list-counter-border-color); + color: color('white'); + font-size: units(2); + font-weight: 700; + text-align: center; + padding-top: units(0.5); + margin-right: units(1.5); +} diff --git a/app/components/barcode_component.html.erb b/app/components/barcode_component.html.erb new file mode 100644 index 00000000000..c41cb0b82b7 --- /dev/null +++ b/app/components/barcode_component.html.erb @@ -0,0 +1,16 @@ +<%# Beware: This component is used in mailer content, so be mindful of email markup compatibility %> +<%= content_tag( + :div, + role: 'figure', + 'aria-labelledby': barcode_caption_id, + class: css_class, + **tag_options, + ) do %> + <%= barcode_html.html_safe %> +
+ <% if label.present? %> + <%= label %>: + <% end %> + <%= formatted_data %> +
+<% end %> diff --git a/app/components/barcode_component.rb b/app/components/barcode_component.rb new file mode 100644 index 00000000000..6316d940031 --- /dev/null +++ b/app/components/barcode_component.rb @@ -0,0 +1,38 @@ +require 'barby' +require 'barby/barcode/code_128' +require 'barby/outputter/html_outputter' + +class BarcodeComponent < BaseComponent + attr_reader :barcode_data, :label, :label_formatter, :tag_options + + def initialize(barcode_data:, label:, label_formatter: nil, **tag_options) + @barcode_data = barcode_data + @label = label + @label_formatter = label_formatter + @tag_options = tag_options + end + + def formatted_data + formatted_data = barcode_data + formatted_data = label_formatter.call(formatted_data) if label_formatter + formatted_data + end + + def barcode_html + html = Barby::Code128.new(barcode_data).to_html(class_name: 'barcode') + # The Barby gem doesn't provide much control over rendered output, so we need to manually slice + # in accessibility features (label as substitute to illegible inner content). + html.gsub( + '>', + %( aria-label="#{t('components.barcode.table_label')}">), + ) + end + + def barcode_caption_id + "barcode-caption-#{unique_id}" + end + + def css_class + [*tag_options[:class], 'display-inline-block margin-0'] + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 130bc7f05ad..2831a2639f7 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -224,6 +224,18 @@ def account_verified(user, email_address, date_time:, sp_name:, disavowal_token: end end + def in_person_ready_to_verify(user, email_address, first_name:, enrollment:) + with_user_locale(user) do + @header = t('in_person_proofing.headings.barcode') + @first_name = first_name + @presenter = Idv::InPerson::ReadyToVerifyPresenter.new(enrollment: enrollment) + mail( + to: email_address.email, + subject: t('user_mailer.in_person_ready_to_verify.subject', app_name: APP_NAME), + ) + end + end + private def email_should_receive_nonessential_notifications?(email) diff --git a/app/presenters/idv/in_person/ready_to_verify_presenter.rb b/app/presenters/idv/in_person/ready_to_verify_presenter.rb index e48fcdced0c..c44f5530f40 100644 --- a/app/presenters/idv/in_person/ready_to_verify_presenter.rb +++ b/app/presenters/idv/in_person/ready_to_verify_presenter.rb @@ -1,31 +1,19 @@ -require 'barby' -require 'barby/barcode/code_128' -require 'barby/outputter/png_outputter' - module Idv module InPerson class ReadyToVerifyPresenter # WILLFIX: With LG-6881, confirm timezone or use deadline from enrollment response. USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'] - delegate :selected_location_details, to: :enrollment + delegate :selected_location_details, :enrollment_code, to: :enrollment def initialize(enrollment:) @enrollment = enrollment end - def barcode_data_url - "data:image/png;base64,#{Base64.strict_encode64(barcode_image_data)}" - end - def formatted_due_date due_date.in_time_zone(USPS_SERVER_TIMEZONE).strftime(I18n.t('time.formats.event_date')) end - def formatted_enrollment_code - EnrollmentCodeFormatter.format(enrollment_code) - end - def selected_location_hours(prefix) selected_location_details['hours'].each do |hours_candidate| hours = hours_candidate["#{prefix}Hours"] @@ -40,7 +28,6 @@ def needs_proof_of_address? private attr_reader :enrollment - delegate :enrollment_code, to: :enrollment def barcode_image_data Barby::Code128C.new(enrollment_code).to_png(margin: 0, xdim: 2) diff --git a/app/views/idv/in_person/ready_to_verify/show.html.erb b/app/views/idv/in_person/ready_to_verify/show.html.erb index 585e4b53b3e..e84c6dc4c06 100644 --- a/app/views/idv/in_person/ready_to_verify/show.html.erb +++ b/app/views/idv/in_person/ready_to_verify/show.html.erb @@ -21,7 +21,7 @@ <% end %> <%= render AlertComponent.new(class: 'margin-y-4', text_tag: :div) do %> -

<%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %>

+

<%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %>

<%= t('in_person_proofing.body.barcode.deadline_restart') %>

<% end %> @@ -31,18 +31,11 @@ <%= render ProcessListComponent.new(heading_level: :h3, class: 'margin-y-3') do |c| %> <% c.item(heading: t('in_person_proofing.process.barcode.heading')) do %>

<%= t('in_person_proofing.process.barcode.info') %>

-
- <%= image_tag( - @presenter.barcode_data_url, - skip_pipeline: true, - alt: t('in_person_proofing.process.barcode.image_alt'), - class: 'display-block margin-bottom-1', - ) %> -
- <%= t('in_person_proofing.process.barcode.caption_label') %>: - <%= @presenter.formatted_enrollment_code %> -
-
+ <%= render BarcodeComponent.new( + barcode_data: @presenter.enrollment_code, + label: t('in_person_proofing.process.barcode.caption_label'), + label_formatter: Idv::InPerson::EnrollmentCodeFormatter.method(:format), + ) %> <% end %> <% c.item(heading: t('in_person_proofing.process.state_id.heading')) do %>

<%= t('in_person_proofing.process.state_id.info') %>

@@ -85,7 +78,7 @@ <%= @presenter.selected_location_details['state'] %> <%= @presenter.selected_location_details['zip5'] %>-<%= @presenter.selected_location_details['zip4'] %> -

<%= t('in_person_proofing.body.barcode.retail_hours') %>

+

<%= t('in_person_proofing.body.barcode.retail_hours') %>

<%= t('date.range', from: t('date.day_names')[0], to: t('date.day_names')[4]) %>: <%= @presenter.selected_location_hours(:weekday) %>
<%= t('date.day_names')[5] %>: <%= @presenter.selected_location_hours(:saturday) %>
diff --git a/app/views/user_mailer/in_person_ready_to_verify.html.erb b/app/views/user_mailer/in_person_ready_to_verify.html.erb new file mode 100644 index 00000000000..9ec3e764a5e --- /dev/null +++ b/app/views/user_mailer/in_person_ready_to_verify.html.erb @@ -0,0 +1,102 @@ +

+ <%= t('user_mailer.in_person_ready_to_verify.greeting', name: @first_name) %>
+ <%= t('user_mailer.in_person_ready_to_verify.intro') %> +

+ + + + + + +
+ <%= image_tag('email/info.png', width: 16, height: 16, alt: '') %> + +

<%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %>

+

<%= t('in_person_proofing.body.barcode.deadline_restart') %>

+
+ +
+

+ <%= t('in_person_proofing.body.barcode.items_to_bring') %> +

+

<%= t('in_person_proofing.body.barcode.emailed_info') %>

+ + + + + + + + + + <% if @presenter.needs_proof_of_address? %> + + + + + <% end %> +
1
+

<%= t('in_person_proofing.process.barcode.heading') %>

+

<%= t('in_person_proofing.process.barcode.info') %>

+ <%= render BarcodeComponent.new( + barcode_data: @presenter.enrollment_code, + label: nil, + label_formatter: Idv::InPerson::EnrollmentCodeFormatter.method(:format), + ) %> +
2
+

<%= t('in_person_proofing.process.state_id.heading') %>

+

<%= t('in_person_proofing.process.state_id.info') %>

+
    + <% t('in_person_proofing.process.state_id.acceptable_documents').each do |document| %> +
  • <%= document %>
  • + <% end %> +
+

<%= t('in_person_proofing.process.state_id.no_other_documents') %>

+
4
+

<%= t('in_person_proofing.process.proof_of_address.heading') %>

+

<%= t('in_person_proofing.process.proof_of_address.info') %>

+
    + <% t('in_person_proofing.process.proof_of_address.acceptable_proof').each do |proof| %> +
  • <%= proof %>
  • + <% end %> +
+
+

+ <%= t('in_person_proofing.body.barcode.items_to_bring_questions') %> + <%= link_to( + t('in_person_proofing.body.barcode.learn_more'), + MarketingSite.help_center_article_url( + category: 'verify-your-identity', + article: 'how-to-verify-in-person', + ), + ) %> +

+
+ +<% if @presenter.selected_location_details.present? %> +
+

<%= @presenter.selected_location_details['name'] %>

+
+ <%= @presenter.selected_location_details['streetAddress'] %>
+ <%= @presenter.selected_location_details['city'] %>, + <%= @presenter.selected_location_details['state'] %> + <%= @presenter.selected_location_details['zip5'] %>-<%= @presenter.selected_location_details['zip4'] %> +
+
<%= t('in_person_proofing.body.barcode.retail_hours') %>
+
+ <%= t('date.range', from: t('date.day_names')[0], to: t('date.day_names')[4]) %>: <%= @presenter.selected_location_hours(:weekday) %>
+ <%= t('date.day_names')[5] %>: <%= @presenter.selected_location_hours(:saturday) %>
+ <%= t('date.day_names')[6] %>: <%= @presenter.selected_location_hours(:sunday) %> +
+
+ <%= @presenter.selected_location_details[:phone] %> +
+
+<% end %> + +

<%= t('in_person_proofing.body.barcode.speak_to_associate') %>

+ +

+ <%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %> + <%= t('in_person_proofing.body.barcode.no_appointment_required') %> +

diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index 8ce946d7589..8f9769790f4 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -5,7 +5,7 @@ } config.action_dispatch.default_headers.merge!( - 'X-Frame-Options' => 'DENY', + 'X-Frame-Options' => IdentityConfig.store.rails_mailer_previews_enabled ? 'SAMEORIGIN' : 'DENY', 'X-XSS-Protection' => '1; mode=block', 'X-Download-Options' => 'noopen', ) diff --git a/config/locales/components/en.yml b/config/locales/components/en.yml index d197121dd90..94915725810 100644 --- a/config/locales/components/en.yml +++ b/config/locales/components/en.yml @@ -1,6 +1,8 @@ --- en: components: + barcode: + table_label: Barcode clipboard_button: label: Copy javascript_required: diff --git a/config/locales/components/es.yml b/config/locales/components/es.yml index 11f564c2f78..cec7ec4d61c 100644 --- a/config/locales/components/es.yml +++ b/config/locales/components/es.yml @@ -1,6 +1,8 @@ --- es: components: + barcode: + table_label: Código de barras clipboard_button: label: Copiar javascript_required: diff --git a/config/locales/components/fr.yml b/config/locales/components/fr.yml index 5011d86f66f..62f05681bf4 100644 --- a/config/locales/components/fr.yml +++ b/config/locales/components/fr.yml @@ -1,6 +1,8 @@ --- fr: components: + barcode: + table_label: Code-barres clipboard_button: label: Copier javascript_required: diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index faf2ce8e0e4..02fcb91e258 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -83,7 +83,6 @@ en: barcode: caption_label: Enrollment code heading: A copy of your barcode - image_alt: Barcode info: Print or scan from your mobile device. proof_of_address: acceptable_proof: diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index 2546488f7e5..8c463aea8c1 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -87,7 +87,6 @@ es: barcode: caption_label: Código de registro heading: Una copia de su código de barras - image_alt: Código de barras info: Imprima o escanee desde su dispositivo móvil. proof_of_address: acceptable_proof: diff --git a/config/locales/in_person_proofing/fr.yml b/config/locales/in_person_proofing/fr.yml index 0e333ff59f4..7767c7520f4 100644 --- a/config/locales/in_person_proofing/fr.yml +++ b/config/locales/in_person_proofing/fr.yml @@ -91,7 +91,6 @@ fr: barcode: caption_label: Code d’inscription heading: Une copie de votre code-barres - image_alt: Code-barres info: Imprimez ou numérisez depuis votre appareil mobile. proof_of_address: acceptable_proof: diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 7b6f7f33011..587cc7c4aa0 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -100,6 +100,11 @@ en: %{app_name} %{help_link} or %{contact_link}. subject: Email address deleted help_link_text: Help Center + in_person_ready_to_verify: + greeting: Hi %{name}, + intro: Here are the details to verify your identity in person at a United States + Post Office near you. + subject: You’re ready to verify your identity with %{app_name} in person letter_reminder: info_html: The letter you are about to receive will contain a confirmation code that helps us verify your address. You can complete the identity diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 9bcc9d62f61..7ac1e5820c8 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -106,6 +106,11 @@ es: %{app_name} %{help_link} o el %{contact_link}. subject: Dirección de correo electrónico eliminada help_link_text: Centro de Ayuda + in_person_ready_to_verify: + greeting: 'Hola, %{name}:' + intro: Estos son los detalles para verificar su identidad en persona en una + oficina de correos de los Estados Unidos cercana a usted. + subject: Está listo para verificar su identidad con %{app_name} en persona letter_reminder: info_html: La carta que está a punto de recibir contendrá un código de confirmación que nos ayudará a verificar su dirección. Puede completar diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 616a7302c7f..f1a56ba36e1 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -109,6 +109,11 @@ fr: veuillez visiter le %{help_link} de %{app_name} ou %{contact_link}. subject: Adresse email supprimée help_link_text: Centre d’aide + in_person_ready_to_verify: + greeting: Bonjour %{name}, + intro: Voici les détails pour vérifier votre identité en personne dans un bureau + de poste des États-Unis près de chez vous. + subject: Vous êtes prêt à vérifier votre identité avec %{app_name} en personne letter_reminder: info_html: La lettre que vous êtes sur le point de recevoir contiendra un code de confirmation nous permettant de vérifier votre adresse. Vous pouvez diff --git a/spec/components/barcode_component_spec.rb b/spec/components/barcode_component_spec.rb new file mode 100644 index 00000000000..44e171fc434 --- /dev/null +++ b/spec/components/barcode_component_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe BarcodeComponent, type: :component do + it 'renders expected content' do + rendered = render_inline BarcodeComponent.new(barcode_data: '1234', label: 'Code') + + caption = page.find_css('table + div', text: 'Code: 1234').first + + expect(rendered).to have_css("table.barcode[aria-label=#{t('components.barcode.table_label')}]") + expect(rendered).to have_css("[role=figure][aria-labelledby=#{caption.attr(:id)}]") + expect(rendered).to have_css('table tbody[aria-hidden=true]') + end + + context 'with tag options' do + it 'renders with attributes' do + rendered = render_inline( + BarcodeComponent.new( + barcode_data: '1234', + label: '', + data: { foo: 'bar' }, + aria: { hidden: 'false' }, + class: 'example', + ), + ) + + expect(rendered).to have_css( + '.example[role=figure][aria-labelledby][data-foo=bar][aria-hidden=false]', + ) + end + end + + context 'with empty label' do + it 'renders label without prefix' do + rendered = render_inline BarcodeComponent.new(barcode_data: '1234', label: '') + + expect(rendered).to have_css('table + div', text: '1234') + end + end + + context 'with label formatter' do + it 'renders formatted label' do + rendered = render_inline BarcodeComponent.new( + barcode_data: '1234', + label: '', + label_formatter: ->(barcode_data) { barcode_data + '5678' }, + ) + + expect(rendered).to have_css('table + div', text: '12345678') + end + end +end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 461a28822b7..2ea771354ab 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -134,6 +134,15 @@ def account_verified ) end + def in_person_ready_to_verify + UserMailer.in_person_ready_to_verify( + user, + email_address_record, + first_name: 'Michael', + enrollment: in_person_enrollment, + ) + end + private def user @@ -148,6 +157,38 @@ def email_address_record unsaveable(EmailAddress.new(email: email_address)) end + def in_person_enrollment + unsaveable( + InPersonEnrollment.new( + user: user, + profile: unsaveable(Profile.new(user: user)), + enrollment_code: '2048702198804358', + created_at: Time.zone.now, + current_address_matches_id: true, + selected_location_details: { + 'name' => 'BALTIMORE — Post Office™', + 'streetAddress' => '900 E FAYETTE ST RM 118', + 'city' => 'BALTIMORE', + 'state' => 'MD', + 'zip5' => '21233', + 'zip4' => '9715', + 'phone' => '555-123-6409', + 'hours' => [ + { + 'weekdayHours' => '8:30 AM - 4:30 PM', + }, + { + 'saturdayHours' => '9:00 AM - 12:00 PM', + }, + { + 'sundayHours' => 'Closed', + }, + ], + }, + ), + ) + end + # Remove #save and #save! to make sure we can't write these made-up records def unsaveable(record) class << record diff --git a/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb b/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb index f0951f55415..ba3e8bc71dc 100644 --- a/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb +++ b/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb @@ -20,14 +20,6 @@ subject(:presenter) { described_class.new(enrollment: enrollment) } - describe '#barcode_data_url' do - subject(:barcode_data_url) { presenter.barcode_data_url } - - it 'returns a valid data URL' do - expect(barcode_data_url).to match URI::DEFAULT_PARSER.make_regexp('data') - end - end - describe '#formatted_due_date' do subject(:formatted_due_date) { presenter.formatted_due_date } @@ -40,16 +32,6 @@ end end - describe '#formatted_enrollment_code' do - subject(:formatted_enrollment_code) { presenter.formatted_enrollment_code } - - it 'returns a formatted enrollment code' do - expect(formatted_enrollment_code).to eq( - Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code), - ) - end - end - describe '#selected_location_details' do subject(:selected_location_details) { presenter.selected_location_details }