Skip to content

Commit

Permalink
Add seniority survey modal (#7105)
Browse files Browse the repository at this point in the history
* Add seniority survey modal

* Update copy

* Correctly import WelcomeModal

* Fix wrong type in `internal.tsx`

* Set max width

* Add links + mutations

* Add logic, and different views

* Refactor

* Add BootcampAdvertisment view

* Set default view to `initial`

* Update copy

* Remove ThanksView, close if person is not a beginner

* Guard against showing both modals quickly after each other

* Show seniority modal on track page too

* Tweak wording

* Fix dropdown_test

* Fix profile_dropdown_test

* Fix user_loads_reputation_test

* Fix views_track_documentation_test

* Update user factory

* Undo changes to application_system_test_case

* Fix weird refute_text failure

* Fix test

* Add more multi-modal guards

---------

Co-authored-by: Jeremy Walker <jez.walker@gmail.com>
  • Loading branch information
dem4ron and iHiD authored Dec 3, 2024
1 parent 4c541e1 commit a571dc1
Show file tree
Hide file tree
Showing 19 changed files with 512 additions and 5 deletions.
4 changes: 4 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ def csp_policy
helper_method :csp_policy
# rubocop:enable Lint/PercentStringArray

def showing_modal? = @showing_modal
def showing_modal! = (@showing_modal = true)
helper_method :showing_modal?, :showing_modal!

private
def set_body_class_header
response.set_header("Exercism-Body-Class", body_class)
Expand Down
16 changes: 16 additions & 0 deletions app/css/ui-kit/buttons.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@
}
}

.btn-slightly-enhanced {
@apply btn-i-filled;
@apply bg-backgroundColorA text-textColor2;
@apply border-1 border-borderColor4;
& > .c-icon {
filter: var(--textColor2-filter);
}
&:not(:disabled):not(.--disabled):hover {
background: rgb(96, 79, 205, 0.02);
&.btn-l {
background: rgb(96, 79, 205, 0.05);
box-shadow: 0px 4px 8px rgba(var(--shadowColorMain), 0.3);
}
}
}

.btn-default {
@apply text-textColor2;
@apply border-1 border-btnBorder;
Expand Down
15 changes: 15 additions & 0 deletions app/helpers/react_components/modals/beg_modal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ class BegModal < ReactComponent
def to_s
return unless show_modal?

showing_modal!

super("beg-modal", {
previous_donor:,
request: {
Expand All @@ -20,9 +22,22 @@ def to_s
})
end

def recently_seen_seniority_modal?
flag = ReactComponents::Modals::SenioritySurveyModal::SHOWN_AT_FLAG
flag_value = session[flag]
return false unless flag_value.present?
return false if DateTime.parse(flag_value) < 5.minutes.ago

true
rescue StandardError
false
end

private
memoize
def show_modal?
return false if showing_modal?
return false if recently_seen_seniority_modal?
return false if current_user.current_subscription
return false if current_user.donated_in_last_35_days?
return false unless current_user.solutions.count >= 5
Expand Down
28 changes: 28 additions & 0 deletions app/helpers/react_components/modals/seniority_survey_modal.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module ReactComponents
module Modals
class SenioritySurveyModal < ReactComponent
SHOWN_AT_FLAG = "shown_seniority_modal_at".freeze

def to_s
return if showing_modal?
return if current_user.seniority

showing_modal!
session[SHOWN_AT_FLAG] = Time.current

super(
"modals-seniority-survey-modal",
{
links: {
hide_modal_endpoint: Exercism::Routes.hide_api_settings_introducer_path(slug),
api_user_endpoint: Exercism::Routes.api_user_url
}
}
)
end

private
def slug = "seniority-survey-modal"
end
end
end
2 changes: 2 additions & 0 deletions app/helpers/react_components/modals/track_welcome_modal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ class TrackWelcomeModal < ReactComponent
initialize_with :track

def to_s
return if showing_modal?
return if current_user.introducer_dismissed?(introducer_slug) || UserTrack.for(current_user,
track).tutorial_exercise_completed?

showing_modal!
super(
"modals-#{modal_slug}",
{
Expand Down
3 changes: 3 additions & 0 deletions app/helpers/react_components/modals/welcome_modal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ module ReactComponents
module Modals
class WelcomeModal < ReactComponent
def to_s
return if showing_modal?
return if current_user.introducer_dismissed?(slug)

if current_user.solutions.count >= 2
current_user.dismiss_introducer!(slug)
return
end

showing_modal!

super(
"modals-welcome-modal",
{
Expand Down
2 changes: 2 additions & 0 deletions app/helpers/view_components/view_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ class ViewComponent
:time_ago_in_words, :pluralize, :number_with_delimiter,
:graphical_icon, :icon, :track_icon, :exercise_icon, :avatar,
:capture_haml,
:showing_modal?, :showing_modal!,
:javascript_include_tag, :request,
:session,
to: :view_context

# This is called when you called `render SomeComponent.new(...)`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React, { useContext } from 'react'
import { Icon } from '@/components/common'
import { FormButton } from '@/components/common/FormButton'
import { ErrorBoundary, ErrorMessage } from '@/components/ErrorBoundary'
import { SenioritySurveyModalContext } from './SenioritySurveyModal'

const DEFAULT_ERROR = new Error('Unable to dismiss modal')

export function BootcampAdvertismentView() {
const { patchCloseModal } = useContext(SenioritySurveyModalContext)
return (
<>
<div className="lhs">
<header>
<h1>Have you heard about the Bootcamp?</h1>

<p className="mb-8">
In January, we're running a one-off{' '}
<strong className="font-semibold text-softEmphasis">
part-time, remote bootcamp for beginners! 🎉
</strong>
</p>
<p className="mb-8">
We're going to teach you all the fundamentals with a hands-on
project-based approach. No stuffy videos, no heavy theory, just lots
of fun coding!
</p>
<p className="mb-8">
It's super affordable, with discounts if you're a student,
unemployed, or live in a country with an emerging economy.
</p>
<p className="mb-20">Watch our intro video to learn more 👉</p>
</header>
<div className="flex gap-8">
<a
href="https://bootcamp.exercism.org"
className="btn-primary btn-l cursor-pointer"
>
Go to the Bootcamp ✨
</a>

<FormButton
status={patchCloseModal.status}
className="btn-secondary btn-l"
type="button"
onClick={patchCloseModal.mutate}
>
Skip &amp; Close
</FormButton>
</div>
<ErrorBoundary resetKeys={[patchCloseModal.status]}>
<ErrorMessage
error={patchCloseModal.error}
defaultError={DEFAULT_ERROR}
/>
</ErrorBoundary>
</div>
<div className="rhs pt-72">
<div className="flex flex-row gap-8 items-center justify-center text-16 text-textColor1 mb-16">
<Icon
icon="exercism-face"
className="filter-textColor1"
alt="exercism-face"
height={16}
width={16}
/>
<div>
<strong className="font-semibold"> Exercism </strong>
Bootcamp
</div>
</div>
<div
className="video relative rounded-8 overflow-hidden !mb-16"
style={{ padding: '56.25% 0 0 0', position: 'relative' }}
>
<iframe
src="https://player.vimeo.com/video/1024390839?h=c2b3bdce14&amp;badge=0&amp;autopause=0&amp;player_id=0&amp;app_id=58479"
title="Introducing the Exercism Bootcamp"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<div className="bubbles">
<div className="bubble">
<Icon category="bootcamp" alt="wave-icon" icon="wave" />
<div className="text">
<strong>Live</strong> teaching
</div>
</div>
<div className="bubble">
<Icon category="bootcamp" alt="fun-icon" icon="fun" />
<div className="text">
<strong>Fun</strong> projects
</div>
</div>
<div className="bubble">
<Icon category="bootcamp" alt="price-icon" icon="price" />
<div className="text">
Priced <strong>fairly</strong>{' '}
</div>
</div>
</div>
</div>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, { useContext, useState, useCallback } from 'react'
import { useMutation } from '@tanstack/react-query'
import { sendRequest } from '@/utils/send-request'
import { assembleClassNames } from '@/utils/assemble-classnames'
import { FormButton } from '@/components/common/FormButton'
import { ErrorBoundary, ErrorMessage } from '@/components/ErrorBoundary'
import { SenioritySurveyModalContext } from './SenioritySurveyModal'
import type { SeniorityLevel } from '../welcome-modal/WelcomeModal'
import { ErrorFallback } from '@/components/common/ErrorFallback'

const DEFAULT_ERROR = new Error('Unable to save seniority level.')

const SENIORITIES: { label: string; value: SeniorityLevel }[] = [
{
label: 'Absolute Beginner',
value: 'absolute_beginner',
},
{
label: 'Beginner',
value: 'beginner',
},
{
label: 'Junior Developer',
value: 'junior',
},
{
label: 'Mid-level Developer',
value: 'mid',
},
{
label: 'Senior Developer',
value: 'senior',
},
]

export function InitialView() {
const { links, setCurrentView, patchCloseModal } = useContext(
SenioritySurveyModalContext
)
const [selected, setSelected] = useState<SeniorityLevel | ''>('')

const {
mutate: setSeniorityMutation,
status: setSeniorityMutationStatus,
error: setSeniorityMutationError,
} = useMutation(
(seniority: SeniorityLevel) => {
const { fetch } = sendRequest({
endpoint: links.apiUserEndpoint + `?user[seniority]=${seniority}`,
method: 'PATCH',
body: null,
})

return fetch
},
{
onSuccess: () => {
if (selected.includes('beginner')) {
setCurrentView('bootcamp-advertisment')
return
}

patchCloseModal.mutate()
},
}
)

const handleSaveSeniorityLevel = useCallback(() => {
if (selected === '') return
setSeniorityMutation(selected)
}, [selected, setSeniorityMutation])

return (
<div className="lhs">
<header>
<h1>Hey there 👋</h1>
<p className="mb-16">
We're expanding Exercism to add content relevant to a wide range of
abilities. To ensure Exercism shows you the right content, please tell
us how experienced you are.
</p>
<h2>How experienced a developer are you?</h2>
</header>
<div className="flex flex-col flex-wrap gap-8 mb-16 text-18">
{SENIORITIES.map((seniority) => (
<button
key={seniority.value}
className={assembleClassNames(
'btn-m btn-slightly-enhanced',
selected === seniority.value
? 'border-prominentLinkColor text-prominentLinkColor'
: ''
)}
onClick={() => setSelected(seniority.value)}
>
{seniority.label}
</button>
))}
</div>

<FormButton
status={setSeniorityMutationStatus}
disabled={selected === ''}
className="btn-primary btn-l w-100"
type="button"
onClick={handleSaveSeniorityLevel}
>
Save my choice
</FormButton>
<p className="!text-14 text-center mt-12">
(This can be updated at any time in your settings)
</p>
<ErrorBoundary
FallbackComponent={ErrorFallback}
resetKeys={[setSeniorityMutationStatus]}
>
<ErrorMessage
error={setSeniorityMutationError}
defaultError={DEFAULT_ERROR}
/>
</ErrorBoundary>
<ErrorBoundary resetKeys={[patchCloseModal.status]}>
<ErrorMessage
error={patchCloseModal.status}
defaultError={DEFAULT_ERROR}
/>
</ErrorBoundary>
</div>
)
}
Loading

0 comments on commit a571dc1

Please sign in to comment.