mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-05 13:21:10 -05:00
350 lines
12 KiB
CoffeeScript
350 lines
12 KiB
CoffeeScript
RootView = require 'views/core/RootView'
|
|
template = require 'templates/account/subscription-view'
|
|
CocoCollection = require 'collections/CocoCollection'
|
|
SubscribeModal = require 'views/core/SubscribeModal'
|
|
Payment = require 'models/Payment'
|
|
stripeHandler = require 'core/services/stripe'
|
|
User = require 'models/User'
|
|
utils = require 'core/utils'
|
|
|
|
# TODO: Link to sponsor id /user/userID instead of plain text name
|
|
# TODO: Link to sponsor email instead of plain text email
|
|
# TODO: Conslidate the multiple class for personal and recipient subscription info into 2 simple server API calls
|
|
# TODO: Track purchase amount based on actual users subscribed for a recipient subscribe event
|
|
# TODO: Validate email address formatting
|
|
# TODO: i18n pluralization for Stripe dialog description
|
|
# TODO: Don't prompt for new card if we have one already, just confirm purchase
|
|
# TODO: bulk discount isn't applied to personal sub
|
|
# TODO: next payment amount incorrect if have an expiring personal sub
|
|
# TODO: consider hiding managed subscription body UI while things are updating to avoid brief legacy data
|
|
# TODO: Next payment info for personal sub displays most recent payment when resubscribing before trial end
|
|
# TODO: PersonalSub and RecipientSubs have similar subscribe APIs
|
|
# TODO: Better recovery from trying to reuse a prepaid
|
|
# TODO: No way to unsubscribe from prepaid subscription
|
|
# TODO: Refactor state machines driving the UI. They've become a hot mess.
|
|
|
|
# TODO: Get basic plan price dynamically
|
|
basicPlanPrice = 999
|
|
basicPlanID = 'basic'
|
|
|
|
module.exports = class SubscriptionView extends RootView
|
|
id: "subscription-view"
|
|
template: template
|
|
|
|
events:
|
|
'click .start-subscription-button': 'onClickStartSubscription'
|
|
'click .end-subscription-button': 'onClickEndSubscription'
|
|
'click .cancel-end-subscription-button': 'onClickCancelEndSubscription'
|
|
'click .confirm-end-subscription-button': 'onClickConfirmEndSubscription'
|
|
'click .recipients-subscribe-button': 'onClickRecipientsSubscribe'
|
|
'click .confirm-recipient-unsubscribe-button': 'onClickRecipientConfirmUnsubscribe'
|
|
'click .recipient-unsubscribe-button': 'onClickRecipientUnsubscribe'
|
|
|
|
subscriptions:
|
|
'subscribe-modal:subscribed': 'onSubscribed'
|
|
'stripe:received-token': 'onStripeReceivedToken'
|
|
|
|
constructor: (options) ->
|
|
super(options)
|
|
prepaidCode = utils.getQueryVariable '_ppc'
|
|
@personalSub = new PersonalSub(@supermodel, prepaidCode)
|
|
@recipientSubs = new RecipientSubs(@supermodel)
|
|
@emailValidator = new EmailValidator(@superModel)
|
|
@personalSub.update => @render?()
|
|
@recipientSubs.update => @render?()
|
|
|
|
# Personal Subscriptions
|
|
|
|
onClickStartSubscription: (e) ->
|
|
if @personalSub.prepaidCode
|
|
@personalSub.subscribe(=> @render?())
|
|
else
|
|
@openModalView new SubscribeModal()
|
|
window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'account subscription view'
|
|
|
|
onSubscribed: ->
|
|
document.location.reload()
|
|
|
|
onClickEndSubscription: (e) ->
|
|
window.tracker?.trackEvent 'Unsubscribe Start'
|
|
@$el.find('.end-subscription-button').blur().addClass 'disabled', 250
|
|
@$el.find('.unsubscribe-feedback').show(500).find('textarea').focus()
|
|
|
|
onClickCancelEndSubscription: (e) ->
|
|
window.tracker?.trackEvent 'Unsubscribe Cancel'
|
|
@$el.find('.unsubscribe-feedback').hide(500).find('textarea').blur()
|
|
@$el.find('.end-subscription-button').focus().removeClass 'disabled', 250
|
|
|
|
onClickConfirmEndSubscription: (e) ->
|
|
message = @$el.find('.unsubscribe-feedback textarea').val().trim()
|
|
@personalSub.unsubscribe(message)
|
|
|
|
# Sponsored subscriptions
|
|
|
|
onClickRecipientsSubscribe: (e) ->
|
|
emails = @$el.find('.recipient-emails').val().split('\n')
|
|
valid = @emailValidator.validateEmails(emails, =>@render?())
|
|
@recipientSubs.startSubscribe(emails) if valid
|
|
|
|
onClickRecipientUnsubscribe: (e) ->
|
|
$(e.target).addClass('hide')
|
|
$(e.target).parent().find('.confirm-recipient-unsubscribe-button').removeClass('hide')
|
|
|
|
onClickRecipientConfirmUnsubscribe: (e) ->
|
|
email = $(e.target).closest('tr').find('td.recipient-email').text()
|
|
@recipientSubs.unsubscribe(email, => @render?())
|
|
|
|
onStripeReceivedToken: (e) ->
|
|
@recipientSubs.finishSubscribe(e.token.id, => @render?())
|
|
|
|
# Helper classes for managing subscription actions and updating UI state
|
|
|
|
class EmailValidator
|
|
|
|
validateEmails: (emails, render) ->
|
|
@lastEmails = emails.join('\n')
|
|
#taken from http://www.regular-expressions.info/email.html
|
|
emailRegex = /[A-z0-9._%+-]+@[A-z0-9.-]+\.[A-z]{2,63}/
|
|
@validEmails = (email for email in emails when emailRegex.test(email.trim().toLowerCase()))
|
|
return @emailsInvalid(render) if @validEmails.length < emails.length
|
|
return @emailsValid(render)
|
|
|
|
emailString: ->
|
|
return unless @validEmails
|
|
return @validEmails.join('\n')
|
|
|
|
emailsInvalid: (render) ->
|
|
@state = "invalid"
|
|
render()
|
|
return false
|
|
|
|
emailsValid: (render) ->
|
|
@state = "valid"
|
|
render()
|
|
return true
|
|
|
|
|
|
class PersonalSub
|
|
constructor: (@supermodel, @prepaidCode) ->
|
|
|
|
subscribe: (render) ->
|
|
return unless @prepaidCode
|
|
|
|
if @prepaidCode is me.get('stripe')?.prepaidCode
|
|
delete @prepaidCode
|
|
return render()
|
|
|
|
@state = 'subscribing'
|
|
@stateMessage = ''
|
|
render()
|
|
|
|
stripeInfo = _.clone(me.get('stripe') ? {})
|
|
stripeInfo.planID = basicPlanID
|
|
stripeInfo.prepaidCode = @prepaidCode
|
|
me.set('stripe', stripeInfo)
|
|
|
|
me.once 'sync', =>
|
|
application.tracker?.trackEvent 'Finished subscription purchase', value: 0
|
|
delete @prepaidCode
|
|
@update(render)
|
|
me.once 'error', (user, response, options) =>
|
|
console.error 'We got an error subscribing with Stripe from our server:', response
|
|
stripeInfo = me.get('stripe') ? {}
|
|
delete stripeInfo.planID
|
|
delete stripeInfo.prepaidCode
|
|
me.set('stripe', stripeInfo)
|
|
xhr = options.xhr
|
|
if xhr.status is 402
|
|
@state = 'declined'
|
|
@stateMessage = ''
|
|
else
|
|
if xhr.status is 403
|
|
delete @prepaidCode
|
|
@state = 'unknown_error'
|
|
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
|
|
render()
|
|
me.patch({headers: {'X-Change-Plan': 'true'}})
|
|
|
|
unsubscribe: (message) ->
|
|
removeStripe = =>
|
|
stripeInfo = _.clone(me.get('stripe'))
|
|
delete stripeInfo.planID
|
|
me.set('stripe', stripeInfo)
|
|
me.once 'sync', ->
|
|
window.tracker?.trackEvent 'Unsubscribe End', message: message
|
|
document.location.reload()
|
|
me.patch({headers: {'X-Change-Plan': 'true'}})
|
|
if message
|
|
$.post '/contact', message: message, subject: 'Cancellation', (response) ->
|
|
removeStripe()
|
|
else
|
|
removeStripe()
|
|
|
|
update: (render) ->
|
|
return unless stripeInfo = me.get('stripe')
|
|
|
|
@state = 'loading'
|
|
|
|
if stripeInfo.sponsorID
|
|
@sponsor = true
|
|
onSubSponsorSuccess = (sponsorInfo) =>
|
|
@sponsorEmail = sponsorInfo.email
|
|
@sponsorName = sponsorInfo.name
|
|
@sponsorID = stripeInfo.sponsorID
|
|
if sponsorInfo.subscription.cancel_at_period_end
|
|
@endDate = new Date(sponsorInfo.subscription.current_period_end * 1000)
|
|
delete @state
|
|
render()
|
|
@supermodel.addRequestResource('sub_sponsor', {
|
|
url: '/db/user/-/sub_sponsor'
|
|
method: 'POST'
|
|
success: onSubSponsorSuccess
|
|
}, 0).load()
|
|
|
|
else if stripeInfo.prepaidCode
|
|
@usingPrepaidCode = true
|
|
delete @state
|
|
render()
|
|
|
|
else if stripeInfo.subscriptionID
|
|
@self = true
|
|
@active = me.isPremium()
|
|
@subscribed = stripeInfo.planID?
|
|
|
|
options = { cache: false, url: "/db/user/#{me.id}/stripe" }
|
|
options.success = (info) =>
|
|
if card = info.card
|
|
@card = "#{card.brand}: x#{card.last4}"
|
|
if sub = info.subscription
|
|
periodEnd = new Date((sub.trial_end or sub.current_period_end) * 1000)
|
|
if sub.cancel_at_period_end
|
|
@activeUntil = periodEnd
|
|
else if sub.discount?.coupon?.id isnt 'free'
|
|
@nextPaymentDate = periodEnd
|
|
@cost = "$#{(sub.plan.amount/100).toFixed(2)}"
|
|
else
|
|
console.error "Could not find personal subscription #{me.get('stripe')?.customerID} #{me.get('stripe')?.subscriptionID}"
|
|
delete @state
|
|
render()
|
|
@supermodel.addRequestResource('personal_payment_info', options).load()
|
|
|
|
payments = new CocoCollection([], { url: '/db/payment', model: Payment, comparator:'_id' })
|
|
payments.once 'sync', ->
|
|
@monthsSubscribed = (x for x in payments.models when not x.get('productID')).length
|
|
render()
|
|
@supermodel.loadCollection(payments, 'payments', {cache: false})
|
|
|
|
else if stripeInfo.free
|
|
@free = stripeInfo.free
|
|
delete @state
|
|
render()
|
|
|
|
else
|
|
delete @state
|
|
render()
|
|
|
|
class RecipientSubs
|
|
constructor: (@supermodel) ->
|
|
@recipients = {}
|
|
@unsubscribingRecipients = []
|
|
|
|
addSubscribing: (email) ->
|
|
@unsubscribingRecipients.push email
|
|
|
|
removeSubscribing: (email) ->
|
|
_.remove(@unsubscribingRecipients, (recipientEmail) -> recipientEmail is email)
|
|
|
|
startSubscribe: (emails) ->
|
|
@recipientEmails = (email.trim().toLowerCase() for email in emails)
|
|
_.remove(@recipientEmails, (email) -> _.isEmpty(email))
|
|
return if @recipientEmails.length < 1
|
|
|
|
window.tracker?.trackEvent 'Start sponsored subscription'
|
|
|
|
# TODO: this sometimes shows a rounded amount (e.g. $8.00)
|
|
currentSubCount = me.get('stripe')?.recipients?.length ? 0
|
|
newSubCount = @recipientEmails.length + currentSubCount
|
|
amount = utils.getSponsoredSubsAmount(basicPlanPrice, newSubCount, me.get('stripe')?.subscriptionID?) - utils.getSponsoredSubsAmount(basicPlanPrice, currentSubCount, me.get('stripe')?.subscriptionID?)
|
|
options = {
|
|
description: "#{@recipientEmails.length} " + $.i18n.t('subscribe.stripe_description', defaultValue: 'Monthly Subscriptions')
|
|
amount: amount
|
|
alipay: if me.get('chinaVersion') or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto'
|
|
alipayReusable: true
|
|
}
|
|
@state = 'start subscribe'
|
|
@stateMessage = ''
|
|
stripeHandler.open(options)
|
|
|
|
finishSubscribe: (tokenID, render) ->
|
|
return unless @state is 'start subscribe' # Don't intercept personal subcribe process
|
|
|
|
@state = 'subscribing'
|
|
@stateMessage = ''
|
|
@justSubscribed = []
|
|
render()
|
|
|
|
stripeInfo = _.clone(me.get('stripe') ? {})
|
|
stripeInfo.token = tokenID
|
|
stripeInfo.subscribeEmails = @recipientEmails
|
|
me.set('stripe', stripeInfo)
|
|
|
|
me.once 'sync', =>
|
|
application.tracker?.trackEvent 'Finished sponsored subscription purchase'
|
|
@update(render)
|
|
me.once 'error', (user, response, options) =>
|
|
console.error 'We got an error subscribing with Stripe from our server:', response
|
|
stripeInfo = me.get('stripe') ? {}
|
|
delete stripeInfo.token
|
|
xhr = options.xhr
|
|
if xhr.status is 402
|
|
@state = 'declined'
|
|
@stateMessage = ''
|
|
else
|
|
@state = 'unknown_error'
|
|
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
|
|
render()
|
|
me.patch({headers: {'X-Change-Plan': 'true'}})
|
|
|
|
unsubscribe: (email, render) ->
|
|
delete @state
|
|
@stateMessage = ''
|
|
delete @justSubscribed
|
|
@addSubscribing(email)
|
|
render()
|
|
stripeInfo = _.clone(me.get('stripe'))
|
|
stripeInfo.unsubscribeEmail = email
|
|
me.set('stripe', stripeInfo)
|
|
me.once 'sync', =>
|
|
@removeSubscribing(email)
|
|
@update(render)
|
|
me.patch({headers: {'X-Change-Plan': 'true'}})
|
|
|
|
update: (render) ->
|
|
delete @state
|
|
delete @stateMessage
|
|
return unless me.get('stripe')?.recipients
|
|
@unsubscribingRecipients = []
|
|
|
|
options = { cache: false, url: "/db/user/#{me.id}/stripe" }
|
|
options.success = (info) =>
|
|
@sponsorSub = info.sponsorSubscription
|
|
if card = info.card
|
|
@card = "#{card.brand}: x#{card.last4}"
|
|
render()
|
|
@supermodel.addRequestResource('recipients_payment_info', options).load()
|
|
|
|
onSubRecipientsSuccess = (recipientsMap) =>
|
|
@recipients = recipientsMap
|
|
count = 0
|
|
for userID, recipient of @recipients
|
|
count++ unless recipient.cancel_at_period_end
|
|
if @recipientEmails? and @justSubscribed? and recipient.emailLower in @recipientEmails
|
|
@justSubscribed.push recipient.emailLower
|
|
@nextPaymentAmount = utils.getSponsoredSubsAmount(basicPlanPrice, count, me.get('stripe')?.subscriptionID?)
|
|
@recipientEmails = []
|
|
render()
|
|
@supermodel.addRequestResource('sub_recipients', {
|
|
url: '/db/user/-/sub_recipients'
|
|
method: 'POST'
|
|
success: onSubRecipientsSuccess
|
|
}, 0).load()
|