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 19 commits into
base: main
Choose a base branch
from
Draft
15 changes: 15 additions & 0 deletions app/controllers/api/generate_bootcamp_affiliate_coupon_code.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class API::GenerateBootcampAffiliateCouponCode < API::BaseController
skip_before_action :authenticate_user!
before_action :authenticate_user

def create
return unless current_user.insider?
return if current_user.bootcamp_affiliate_coupon_code.present?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are better places into the command below (the one is already there and I'll add the insiders guard too). I prefer keeping all the logic in one place.

Also, you can't just "return" here as you need to render json, so it should be return render(json: {}) to be a valid response for a JSON API. You could also raise an error of some description like raise "Not insider" which would get caught by your block, but as we have standardised error messages in the API, that's probably not ideal.

So I would make this method:

code = User::GenerateBootcampAffiliateCouponCode.(current_user)
render json: { coupon_code: code }

or if you want to raise an error if the code is missing:

code = User::GenerateBootcampAffiliateCouponCode.(current_user)
if code 
  render json: { coupon_code: code }
else
  render_403(:could_not_generate_coupon_code)

And then you need to add translations for could_not_generate_coupon_code (see config/locales/api/en.yml)


User::GenerateBootcampAffiliateCouponCode.(current_user)

render json: { coupon_code: current_user.bootcamp_affiliate_coupon_code }
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
15 changes: 15 additions & 0 deletions app/controllers/api/generate_bootcamp_free_coupon_code.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class API::GenerateBootcampFreeCouponCode < API::BaseController
skip_before_action :authenticate_user!
before_action :authenticate_user

def create
return if current_user.insiders_status != :active_lifetime
return if current_user.bootcamp_free_coupon_code.present?

User::GenerateBootcampFreeCouponCode.(current_user)

render json: { coupon_code: current_user.bootcamp_free_coupon_code }
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
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
18 changes: 18 additions & 0 deletions app/helpers/react_components/settings/bootcamp_affiliate_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module ReactComponents
module Settings
class BootcampAffiliateForm < ReactComponent
def to_s
super("settings-bootcamp-affiliate-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
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
113 changes: 113 additions & 0 deletions app/javascript/components/settings/BootcampAffiliateForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useState, useCallback } from 'react'
import { Icon, GraphicalIcon } from '@/components/common'
import { FormButton } from '@/components/common/FormButton'
import { FormMessage } from './FormMessage'
import { useMutation } from '@tanstack/react-query'
import { sendRequest } from '@/utils/send-request'

type Links = {
update: string
insidersPath: string
}

export type UserPreferences = {
hideWebsiteAdverts: boolean
}

const DEFAULT_ERROR = new Error('Unable to change preferences')

export default function BootcampAffiliateForm({
defaultPreferences,
insidersStatus,
links,
}: {
defaultPreferences: UserPreferences
insidersStatus: string
links: Links
}): JSX.Element {
const [hideAdverts, setHideAdverts] = useState(
defaultPreferences.hideWebsiteAdverts
)

const {
mutate: mutation,
status,
error,
} = useMutation(async () => {
const { fetch } = sendRequest({
endpoint: links.update,
method: 'PATCH',
body: JSON.stringify({
user_preferences: { hide_website_adverts: hideAdverts },
}),
})

return fetch
})

const handleSubmit = useCallback(
(e) => {
e.preventDefault()

mutation()
},
[mutation]
)

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

return (
<form data-turbo="false" onSubmit={handleSubmit}>
<h2>Bootcamp Affiliate</h2>
<InfoMessage
isInsider={isInsider}
insidersStatus={insidersStatus}
insidersPath={links.insidersPath}
/>
<button className="btn btn-primary">Click to generate code</button>
</form>
)
}

export function InfoMessage({
insidersStatus,
insidersPath,
isInsider,
}: {
insidersStatus: string
insidersPath: string
isInsider: boolean
}): JSX.Element {
if (isInsider) {
return (
<p className="text-p-base mb-16">
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>
)
}
}
151 changes: 151 additions & 0 deletions app/javascript/components/settings/InsiderBenefitsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, { useState, useCallback } from 'react'
import { Icon, GraphicalIcon } from '@/components/common'
import { FormButton } from '@/components/common/FormButton'
import { FormMessage } from './FormMessage'
import { useMutation } from '@tanstack/react-query'
import { sendRequest } from '@/utils/send-request'

type Links = {
update: string
insidersPath: string
}

export type UserPreferences = {
hideWebsiteAdverts: boolean
}

const DEFAULT_ERROR = new Error('Unable to change preferences')

export default function InsiderBenefitsForm({
defaultPreferences,
insidersStatus,
links,
}: {
defaultPreferences: UserPreferences
insidersStatus: string
links: Links
}): JSX.Element {
const [hideAdverts, setHideAdverts] = useState(
defaultPreferences.hideWebsiteAdverts
)

const {
mutate: mutation,
status,
error,
} = useMutation(async () => {
const { fetch } = sendRequest({
endpoint: links.update,
method: 'PATCH',
body: JSON.stringify({
user_preferences: { hide_website_adverts: hideAdverts },
}),
})

return fetch
})

const handleSubmit = useCallback(
(e) => {
e.preventDefault()

mutation()
},
[mutation]
)

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

return (
<form data-turbo="false" onSubmit={handleSubmit}>
<h2>Insider benefits</h2>
<InfoMessage
isInsider={isInsider}
insidersStatus={insidersStatus}
insidersPath={links.insidersPath}
/>
<label className="c-checkbox-wrapper">
<input
type="checkbox"
disabled={!isInsider}
checked={hideAdverts}
onChange={(e) => setHideAdverts(e.target.checked)}
/>
<div className="row">
<div className="c-checkbox">
<GraphicalIcon icon="checkmark" />
</div>
Hide website adverts
</div>
</label>
<div className="form-footer">
<FormButton
disabled={!isInsider}
status={status}
className="btn-primary btn-m"
>
Change preferences
</FormButton>
<FormMessage
status={status}
defaultError={DEFAULT_ERROR}
error={error}
SuccessMessage={SuccessMessage}
/>
</div>
</form>
)
}

const SuccessMessage = () => {
return (
<div className="status success">
<Icon icon="completed-check-circle" alt="Success" />
Your preferences have been updated
</div>
)
}

export function InfoMessage({
insidersStatus,
insidersPath,
isInsider,
}: {
insidersStatus: string
insidersPath: string
isInsider: boolean
}): JSX.Element {
if (isInsider) {
return (
<p className="text-p-base mb-16">
These are exclusive options to enhance your experience as an Exercism
Insdier
</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>
)
}
}
28 changes: 28 additions & 0 deletions app/javascript/packs/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ const PasswordForm = lazy(() => import('@/components/settings/PasswordForm'))
const UserPreferencesForm = lazy(
() => import('@/components/settings/UserPreferencesForm')
)
const InsiderBenefitsForm = lazy(
() => import('@/components/settings/InsiderBenefitsForm')
)
const BootcampAffiliateForm = lazy(
() => import('@/components/settings/BootcampAffiliateForm')
)
const TokenForm = lazy(() => import('@/components/settings/TokenForm'))
const ThemePreferenceForm = lazy(
() => import('@/components/settings/ThemePreferenceForm')
Expand Down Expand Up @@ -469,6 +475,28 @@ initReact({
/>
</Suspense>
),
'settings-insider-benefits-form': (data: any) => (
<Suspense fallback={RenderLoader()}>
<InsiderBenefitsForm
defaultPreferences={camelizeKeysAs<{ hideWebsiteAdverts: boolean }>(
data.preferences
)}
insidersStatus={data.insider_status}
links={camelizeKeysAs<ThemePreferenceLinks>(data.links)}
/>
</Suspense>
),
'settings-bootcamp-affiliate-form': (data: any) => (
<Suspense fallback={RenderLoader()}>
<BootcampAffiliateForm
defaultPreferences={camelizeKeysAs<{ hideWebsiteAdverts: boolean }>(
data.preferences
)}
insidersStatus={data.insider_status}
links={camelizeKeysAs<ThemePreferenceLinks>(data.links)}
/>
</Suspense>
),
'settings-theme-preference-form': (data: any) => (
<Suspense fallback={RenderLoader()}>
<ThemePreferenceForm
Expand Down
Loading
Loading