Skip to content

Commit

Permalink
Add insiders settings (#7151)
Browse files Browse the repository at this point in the history
* Add InsiderBenefitsForm

* Add component

* Add migration

* Progress

* Sort things out better

* Permit editing hide thingy

* Pass down props

* Disable form for non insiders

* Remove unused import

* Create an insiders settings page

* Add new components

* Add API endpoints

* Improve code generation services

* Simplify things

* Refine things

* Finalise affiliate form

* Rename everything, add free coupon form

* Add rendering tests

* Add a WIP generate test

* Stub affiliate generation in test

* Restore schema

* Fix missing cols from schema

* Refine user changes hide adverts test

* Tweaks for copy

* Fix typo

* Fix wrong prop

* Adjust all tests

* Add missing fields

* Fix wrong method call

---------

Co-authored-by: Jeremy Walker <jez.walker@gmail.com>
  • Loading branch information
dem4ron and iHiD authored Nov 21, 2024
1 parent fdd166d commit a81de57
Show file tree
Hide file tree
Showing 26 changed files with 711 additions and 32 deletions.
5 changes: 4 additions & 1 deletion app/commands/user/bootcamp/subscribe_to_onboarding_emails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ def add_subscriber!
"has_paid": bootcamp_data.paid?,
"package": bootcamp_data.package,
"country_name": bootcamp_data.country_name,
"payment_url": bootcamp_data.payment_url
"payment_url": bootcamp_data.payment_url,
"price": bootcamp_data.price,
"has_discount": bootcamp_data.has_discount?,
"discount_percentage": bootcamp_data.discount_percentage
}
},
HEADERS
Expand Down
11 changes: 6 additions & 5 deletions app/commands/user/generate_bootcamp_affiliate_coupon_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ class User::GenerateBootcampAffiliateCouponCode

def call
# Easy cheap guard
return if user_data.bootcamp_affiliate_coupon_code.present?
return user_data.bootcamp_affiliate_coupon_code if user_data.bootcamp_affiliate_coupon_code.present?

# Now things get expensive with Stripe call and lock below
code = generate_coupon_code
user_data.with_lock do
return if user_data.bootcamp_affiliate_coupon_code.present?
generate_coupon_code.tap do |code|
user_data.with_lock do
return if user_data.bootcamp_affiliate_coupon_code.present? # rubocop:disable Lint/NonLocalExitFromIterator

user_data.update!(bootcamp_affiliate_coupon_code: code)
user_data.update!(bootcamp_affiliate_coupon_code: code)
end
end
end

Expand Down
13 changes: 8 additions & 5 deletions app/commands/user/generate_bootcamp_free_coupon_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ class User::GenerateBootcampFreeCouponCode
initialize_with :user

def call
return unless user.lifetime_insider?

# Easy cheap guard
return if user_data.bootcamp_free_coupon_code.present?
return user_data.bootcamp_free_coupon_code if user_data.bootcamp_free_coupon_code.present?

# Now things get expensive with Stripe call and lock below
code = generate_coupon_code
user_data.with_lock do
return if user_data.bootcamp_free_coupon_code.present?
generate_coupon_code.tap do |code|
user_data.with_lock do
return if user_data.bootcamp_free_coupon_code.present? # rubocop:disable Lint/NonLocalExitFromIterator

user_data.update!(bootcamp_free_coupon_code: code)
user_data.update!(bootcamp_free_coupon_code: code)
end
end
end

Expand Down
21 changes: 21 additions & 0 deletions app/controllers/api/settings/user_preferences_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,34 @@ def disable_solution_comments
respond_to_enabling_comments!
end

def bootcamp_affiliate_coupon_code
code = User::GenerateBootcampAffiliateCouponCode.(current_user)

if code
render json: { coupon_code: code }
else
render_403(:could_not_generate_coupon_code)
end
end

def bootcamp_free_coupon_code
code = User::GenerateBootcampFreeCouponCode.(current_user)

if code
render json: { coupon_code: code }
else
render_403(:could_not_generate_coupon_code)
end
end

private
def user_preferences_params
params.
require(:user_preferences).
permit(*User::Preferences.keys).tap do |ps|
# TODO: Add a test for this
ps[:theme] = "light" if ps[:theme] == "dark" && !current_user.insider?
ps[:hide_website_adverts] = false unless current_user.insider?
end
end

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ def api_cli; end

def communication_preferences; end

def insiders; end

def donations
@payments = current_user.payments.includes(:subscription).order(id: :desc)
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module ReactComponents
module Settings
class BootcampAffiliateCouponForm < ReactComponent
def to_s
super("settings-bootcamp-affiliate-coupon-form", {
insiders_status: current_user.insiders_status,
bootcamp_affiliate_coupon_code: current_user.bootcamp_affiliate_coupon_code,
links: {
insiders_path: Exercism::Routes.insiders_path,
bootcamp_affiliate_coupon_code: Exercism::Routes.bootcamp_affiliate_coupon_code_api_settings_user_preferences_url
}
})
end
end
end
end
15 changes: 15 additions & 0 deletions app/helpers/react_components/settings/bootcamp_free_coupon_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module ReactComponents
module Settings
class BootcampFreeCouponForm < ReactComponent
def to_s
super("settings-bootcamp-free-coupon-form", {
insiders_status: current_user.insiders_status,
bootcamp_free_coupon_code: current_user.bootcamp_free_coupon_code,
links: {
bootcamp_free_coupon_code: Exercism::Routes.bootcamp_free_coupon_code_api_settings_user_preferences_url
}
})
end
end
end
end
18 changes: 18 additions & 0 deletions app/helpers/react_components/settings/insider_benefits_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module ReactComponents
module Settings
class InsiderBenefitsForm < ReactComponent
def to_s
super("settings-insider-benefits-form", {
preferences:,
insiders_status: current_user.insiders_status,
links: {
update: Exercism::Routes.api_settings_user_preferences_url,
insiders_path: Exercism::Routes.insiders_path
}
})
end

def preferences = current_user.preferences.slice(:hide_website_adverts)
end
end
end
8 changes: 1 addition & 7 deletions app/helpers/view_components/partner/advert.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,9 @@ def show_advert?
return false if request.is_crawler?
return true if preview
return false unless advert
return false if current_user&.hide_website_adverts?

true
end

ALLOWED_DATES = [
Date.new(2023, 8, 16)..Date.new(2023, 8, 21),
Date.new(2023, 9, 14)..Date.new(2023, 9, 17),
Date.new(2023, 10, 13)..Date.new(2023, 10, 16),
Date.new(2023, 11, 3)..Date.new(2023, 11, 5)
].map(&:to_a).flatten.freeze
end
end
3 changes: 2 additions & 1 deletion app/helpers/view_components/settings_nav.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ def to_s
item_for("Integrations", :integrations_settings, :integrations),
item_for("Preferences", :user_preferences_settings, :preferences),
item_for("Communication Preferences", :communication_preferences_settings, :communication),
item_for("Donations", :donations_settings, :donations)
item_for("Donations", :donations_settings, :donations),
item_for("Insiders", :insiders_settings, :insiders)
]

tag.nav(class: "settings-nav") do
Expand Down
138 changes: 138 additions & 0 deletions app/javascript/components/settings/BootcampAffiliateCouponForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useState, useCallback } from 'react'
import { fetchJSON } from '@/utils/fetch-json'
import CopyToClipboardButton from '../common/CopyToClipboardButton'

type Links = {
bootcampAffiliateCouponCode: string
insidersPath: string
}

export default function BootcampAffiliateCouponForm({
insidersStatus,
bootcampAffiliateCouponCode,
links,
}: {
insidersStatus: string
bootcampAffiliateCouponCode: string
links: Links
}): JSX.Element {
const [couponCode, setCouponCode] = useState(bootcampAffiliateCouponCode)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

const generateCouponCode = useCallback(async () => {
setLoading(true)
try {
const data = await fetchJSON<{ couponCode: string }>(
links.bootcampAffiliateCouponCode,
{
method: 'POST',
body: null,
}
)
setCouponCode(data.couponCode)
setError(null)
} catch (err) {
console.error('Error generating coupon code:', err)
setError('Failed to generate coupon code. Please try again.')
} finally {
setLoading(false)
}
}, [links])

const isInsider =
insidersStatus == 'active' || insidersStatus == 'active_lifetime'

return (
<div>
<h2 className="!mb-8">Bootcamp Affiliate Coupon</h2>
<InfoMessage
isInsider={isInsider}
insidersStatus={insidersStatus}
insidersPath={links.insidersPath}
couponCode={couponCode}
/>

{couponCode ? (
<CopyToClipboardButton textToCopy={couponCode} />
) : (
<button
id="generate-affiliate-coupon-code-button"
onClick={generateCouponCode}
disabled={!isInsider || loading}
type="button"
className="btn btn-primary"
>
{loading
? 'Generating code...'
: 'Generate your Affiliate Discount code'}
</button>
)}
<ErrorMessage error={error} />
</div>
)
}

export function InfoMessage({
insidersStatus,
insidersPath,
isInsider,
couponCode,
}: {
insidersStatus: string
insidersPath: string
isInsider: boolean
couponCode: string
}): JSX.Element {
if (isInsider) {
return (
<>
<p className="text-p-base mb-12">
To thank you for being an Insider and to help increase the amount of
people signing up to Exercism's{' '}
<a href="https://bootcamp.exercism/org">Learn to Code Bootcamp</a>, we
are giving all Insiders an{' '}
<strong className="font-semibold">Discount Affiliate code</strong>.
</p>
<p className="text-p-base mb-12">
This code gives a 20% discount for the bootcamp (on top of any
geographical discount). And for everyone that signs up,{' '}
<strong>we'll give you 20%</strong> of whatever they pay.
</p>
<p className="text-p-base mb-16">
Please help us spread the word. Send this code to your friends, post
it on social media. Maybe even print it out on postcards and put it
through your neighbours doors?
</p>
</>
)
}

switch (insidersStatus) {
case 'eligible':
case 'eligible_lifetime':
return (
<p className="text-p-base mb-16">
You&apos;re eligible to join Insiders.{' '}
<a href={insidersPath}>Get started here.</a>
</p>
)
default:
return (
<p className="text-p-base mb-16">
Exercism Insiders can access 20% off Exercism's{' '}
<a href="https://bootcamp.exercism/org">Learn to Code Bootcamp</a>,
and receive 20% of all sales when someone uses their voucher code.
</p>
)
}
}

function ErrorMessage({ error }: { error: string | null }) {
if (!error) return null
return (
<div className="c-alert--danger text-15 font-body mt-10 normal-case py-8 px-16">
{error}
</div>
)
}
78 changes: 78 additions & 0 deletions app/javascript/components/settings/BootcampFreeCouponForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { useState, useCallback } from 'react'
import { fetchJSON } from '@/utils/fetch-json'
import CopyToClipboardButton from '../common/CopyToClipboardButton'

type Links = {
bootcampFreeCouponCode: string
}

export default function BootcampFreeCouponForm({
bootcampFreeCouponCode,
links,
}: {
bootcampFreeCouponCode: string
links: Links
}): JSX.Element {
const [couponCode, setCouponCode] = useState(bootcampFreeCouponCode)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

const generateCouponCode = useCallback(async () => {
setLoading(true)
try {
const data = await fetchJSON<{ couponCode: string }>(
links.bootcampFreeCouponCode,
{
method: 'POST',
body: null,
}
)
setCouponCode(data.couponCode)
setError(null)
} catch (err) {
console.error('Error generating coupon code:', err)
setError('Failed to generate coupon code. Please try again.')
} finally {
setLoading(false)
}
}, [links])

return (
<div>
<h2 className="!mb-8">Free Seat on the Bootcamp</h2>
<p className="text-p-base mb-12">
As a lifetime insider you're eligible for a free seat on Exercism's{' '}
<a href="https://bootcamp.exercism/org">Learn to Code Bootcamp</a>.
</p>
<p className="text-p-base mb-16">
To claim your free seat, we're providing you with a discount code that
you can use at the checkout for a 100% discount. You can use it for
yourself, give it to a friend, offer it to a charity, post it on social
media, or anything else you feel appropriate.
</p>

{couponCode ? (
<CopyToClipboardButton textToCopy={couponCode} />
) : (
<button
onClick={generateCouponCode}
disabled={loading}
type="button"
className="btn btn-primary"
>
{loading ? 'Generating code...' : 'Click to generate code'}
</button>
)}
<ErrorMessage error={error} />
</div>
)
}

function ErrorMessage({ error }: { error: string | null }) {
if (!error) return null
return (
<div className="c-alert--danger text-15 font-body mt-10 normal-case py-8 px-16">
{error}
</div>
)
}
Loading

0 comments on commit a81de57

Please sign in to comment.