codecombat/app/views/account/SubscriptionView.coffee
2015-12-26 14:21:06 +00:00

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()