Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add insiders settings #7151

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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
Expand All @@ -14,6 +14,7 @@ def call

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

private
Expand Down
6 changes: 5 additions & 1 deletion app/commands/user/generate_bootcamp_free_coupon_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ 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
Expand All @@ -14,6 +16,8 @@ def call

user_data.update!(bootcamp_free_coupon_code: code)
end

code
end

private
Expand Down
20 changes: 20 additions & 0 deletions app/controllers/api/settings/user_preferences_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ 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.
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
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
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
127 changes: 127 additions & 0 deletions app/javascript/components/settings/BootcampAffiliateCouponForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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>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...' : 'Click to generate 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-16">
{couponCode
? 'You can save 20% on the bootcamp with this affiliate code.'
: "You've not yet generated your affiliate code."}
</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">
These are exclusive options for Exercism Insiders.&nbsp;
<strong>
<a className="text-prominentLinkColor" href={insidersPath}>
Donate to Exercism
</a>
</strong>{' '}
and become an Insider to access these benefits with Dark Mode, ChatGPT
integration and more.
</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>
)
}
73 changes: 73 additions & 0 deletions app/javascript/components/settings/BootcampFreeCouponForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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>Bootcamp Free Coupon</h2>
<p className="text-p-base mb-16">
{couponCode
? 'You can use this coupon once to get access to the bootcamp for free.'
: "As a lifetime insider you're eligible for a free bootcamp coupon."}
</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
Loading