From 42dac058e18d43a698b863b45e9a36cd5727cdd6 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Wed, 8 Apr 2015 14:34:05 -0500 Subject: [PATCH 01/12] VRFS-2884 - Update things in session and FTUE for default profile --- db/manifest | 3 +- db/up/show_whats_next_count.sql | 1 + ruby/lib/jam_ruby/models/connection.rb | 17 +- .../models/music_session_user_history.rb | 2 +- .../assets/javascripts/client_init.js.coffee | 18 ++ .../dialog/gettingStartedDialog.js | 26 +- .../dialog/sessionSettingsDialog.js | 7 + .../dialog/singlePlayerProfileGuard.js.coffee | 57 ++++ .../javascripts/everywhere/everywhere.js | 3 +- web/app/assets/javascripts/fakeJamClient.js | 22 +- .../javascripts/scheduled_session.js.erb | 19 +- web/app/assets/javascripts/session.js | 285 +++++++++++------- web/app/assets/javascripts/sessionModel.js | 4 +- web/app/assets/javascripts/session_utils.js | 26 +- web/app/assets/javascripts/utils.js | 107 ++++++- .../javascripts/wizard/gear/gear_wizard.js | 5 + .../wizard/gear/step_network_test.js | 32 +- .../wizard/gear/step_select_gear.js | 44 ++- .../assets/javascripts/wizard/gear_test.js | 17 +- .../assets/javascripts/wizard/gear_utils.js | 237 ++++++++++----- web/app/assets/javascripts/wizard/wizard.js | 7 +- .../assets/stylesheets/client/help.css.scss | 4 + .../stylesheets/client/session.css.scss | 37 +++ .../client/wizard/gearWizard.css.scss | 6 +- .../dialogs/gettingStartDialog.css.scss | 19 ++ .../dialogs/singlePlayerProfileGuard.css.scss | 29 ++ web/app/controllers/api_users_controller.rb | 1 + web/app/views/api_users/show.rabl | 2 +- web/app/views/clients/_help.html.slim | 23 ++ web/app/views/clients/_network_test.html.haml | 2 +- web/app/views/clients/_session.html.slim | 6 + web/app/views/clients/index.html.erb | 8 +- web/app/views/dialogs/_dialogs.html.haml | 1 + .../dialogs/_gettingStartedDialog.html.slim | 26 +- .../_singlePlayerProfileGuard.html.slim | 22 ++ .../features/getting_started_dialog_spec.rb | 33 +- .../active_music_sessions_api_spec.rb | 7 +- 37 files changed, 897 insertions(+), 268 deletions(-) create mode 100644 db/up/show_whats_next_count.sql create mode 100644 web/app/assets/javascripts/client_init.js.coffee create mode 100644 web/app/assets/javascripts/dialog/singlePlayerProfileGuard.js.coffee create mode 100644 web/app/assets/stylesheets/dialogs/singlePlayerProfileGuard.css.scss create mode 100644 web/app/views/dialogs/_singlePlayerProfileGuard.html.slim diff --git a/db/manifest b/db/manifest index 1294b5a96..ba11edce4 100755 --- a/db/manifest +++ b/db/manifest @@ -273,4 +273,5 @@ drop_position_unique_jam_track.sql recording_client_metadata.sql preview_support_mp3.sql jam_track_duration.sql -sales.sql \ No newline at end of file +sales.sql +show_whats_next_count.sql \ No newline at end of file diff --git a/db/up/show_whats_next_count.sql b/db/up/show_whats_next_count.sql new file mode 100644 index 000000000..82e50d469 --- /dev/null +++ b/db/up/show_whats_next_count.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN show_whats_next_count INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/connection.rb b/ruby/lib/jam_ruby/models/connection.rb index 26dd417fe..5a5378b91 100644 --- a/ruby/lib/jam_ruby/models/connection.rb +++ b/ruby/lib/jam_ruby/models/connection.rb @@ -24,11 +24,12 @@ module JamRuby validates :metronome_open, :inclusion => {:in => [true, false]} validates :as_musician, :inclusion => {:in => [true, false, nil]} validates :client_type, :inclusion => {:in => CLIENT_TYPES} - validates_numericality_of :last_jam_audio_latency, greater_than:0, :allow_nil => true + validates_numericality_of :last_jam_audio_latency, greater_than: 0, :allow_nil => true validate :can_join_music_session, :if => :joining_session? validate :user_or_latency_tester_present - after_save :require_at_least_one_track_when_in_session, :if => :joining_session? + # this is no longer required with the new no-input profile + #after_save :require_at_least_one_track_when_in_session, :if => :joining_session? after_create :did_create after_save :report_add_participant @@ -62,11 +63,11 @@ module JamRuby def state_message case self.aasm_state.to_sym when CONNECT_STATE - 'Connected' - when STALE_STATE - 'Stale' + 'Connected' + when STALE_STATE + 'Stale' else - 'Idle' + 'Idle' end end @@ -85,7 +86,7 @@ module JamRuby def joining_session? joining_session end - + def can_join_music_session # puts "can_join_music_session: #{music_session_id} was #{music_session_id_was}" if music_session_id_changed? @@ -183,8 +184,8 @@ module JamRuby end def associate_tracks(tracks) + self.tracks.clear() unless tracks.nil? - self.tracks.clear() tracks.each do |track| t = Track.new t.instrument = Instrument.find(track["instrument_id"]) diff --git a/ruby/lib/jam_ruby/models/music_session_user_history.rb b/ruby/lib/jam_ruby/models/music_session_user_history.rb index 430c897a1..bca0ecccc 100644 --- a/ruby/lib/jam_ruby/models/music_session_user_history.rb +++ b/ruby/lib/jam_ruby/models/music_session_user_history.rb @@ -43,7 +43,7 @@ module JamRuby session_user_history.music_session_id = music_session_id session_user_history.user_id = user_id session_user_history.client_id = client_id - session_user_history.instruments = tracks.map {|t| t[:instrument_id]}.join("|") + session_user_history.instruments = tracks.map {|t| t[:instrument_id]}.join("|") if tracks session_user_history.save end diff --git a/web/app/assets/javascripts/client_init.js.coffee b/web/app/assets/javascripts/client_init.js.coffee new file mode 100644 index 000000000..20d0e76cc --- /dev/null +++ b/web/app/assets/javascripts/client_init.js.coffee @@ -0,0 +1,18 @@ +# one time init stuff for the /client view + + +$ = jQuery +context = window +context.JK ||= {}; + +context.JK.ClientInit = class ClientInit + constructor: () -> + @logger = context.JK.logger + @gearUtils = context.JK.GearUtils + + init: () => + if context.gon.isNativeClient + this.nativeClientInit() + + nativeClientInit: () => + @gearUtils.bootstrapDefaultPlaybackProfile(); diff --git a/web/app/assets/javascripts/dialog/gettingStartedDialog.js b/web/app/assets/javascripts/dialog/gettingStartedDialog.js index 6fc114f74..db80c15ad 100644 --- a/web/app/assets/javascripts/dialog/gettingStartedDialog.js +++ b/web/app/assets/javascripts/dialog/gettingStartedDialog.js @@ -9,6 +9,9 @@ var $dialog = null; var $dontShowAgain = null; var $setupGearBtn = null; + var $browserJamTrackBtn = null; + var $jamTrackSection = null; + var $jamTracksLimitedTime = null; function handleStartAudioQualification() { @@ -45,6 +48,12 @@ return false; }) + $browserJamTrackBtn.click(function() { + app.layout.closeDialog('getting-started') + window.location = '/client#/jamtrack' + return false; + }) + $('#getting-started-dialog a.facebook-invite').on('click', function (e) { invitationDialog.showFacebookDialog(e); }); @@ -59,13 +68,21 @@ } function beforeShow() { + app.user().done(function(user) { + var jamtrackRule = user.free_jamtrack ? 'has-free-jamtrack' : 'no-free-jamtrack' + $jamTrackSection.removeClass('has-free-jamtrack').removeClass('no-free-jamtrack').addClass(jamtrackRule) + if(user.free_jamtrack) { + $jamTracksLimitedTime.removeClass('hidden') + } + }) } function beforeHide() { + var showWhatsNext = !$dontShowAgain.is(':checked') + app.user().done(function(user) { + app.updateUserModel({show_whats_next: showWhatsNext, show_whats_next_count: user.show_whats_next_count + 1}) + }) - if ($dontShowAgain.is(':checked')) { - app.updateUserModel({show_whats_next: false}) - } } function initializeButtons() { @@ -84,6 +101,9 @@ $dialog = $('#getting-started-dialog'); $dontShowAgain = $dialog.find('#show_getting_started'); $setupGearBtn = $dialog.find('.setup-gear-btn') + $browserJamTrackBtn = $dialog.find('.browse-jamtrack'); + $jamTrackSection = $dialog.find('.get-a-free-jamtrack-section') + $jamTracksLimitedTime = $dialog.find('.jamtracks-limited-time') registerEvents(); diff --git a/web/app/assets/javascripts/dialog/sessionSettingsDialog.js b/web/app/assets/javascripts/dialog/sessionSettingsDialog.js index ea18fcd01..0c47eb20e 100644 --- a/web/app/assets/javascripts/dialog/sessionSettingsDialog.js +++ b/web/app/assets/javascripts/dialog/sessionSettingsDialog.js @@ -3,6 +3,7 @@ context.JK = context.JK || {}; context.JK.SessionSettingsDialog = function(app, sessionScreen) { var logger = context.JK.logger; + var gearUtils = context.JK.GearUtilsInstance; var $dialog; var $screen = $('#session-settings'); var $selectedFilenames = $screen.find('#selected-filenames'); @@ -15,6 +16,8 @@ function beforeShow(data) { + var canPlayWithOthers = gearUtils.canPlayWithOthers(); + context.JK.GenreSelectorHelper.render('#session-settings-genre'); $dialog = $('[layout-id="session-settings"]'); @@ -72,6 +75,10 @@ context.JK.dropdown($('#session-settings-language')); context.JK.dropdown($('#session-settings-musician-access')); context.JK.dropdown($('#session-settings-fan-access')); + + var easyDropDownState = canPlayWithOthers.canPlay ? 'enable' : 'disable' + $('#session-settings-musician-access').easyDropDown(easyDropDownState) + $('#session-settings-fan-access').easyDropDown(easyDropDownState) } function saveSettings(evt) { diff --git a/web/app/assets/javascripts/dialog/singlePlayerProfileGuard.js.coffee b/web/app/assets/javascripts/dialog/singlePlayerProfileGuard.js.coffee new file mode 100644 index 000000000..d94a21d6a --- /dev/null +++ b/web/app/assets/javascripts/dialog/singlePlayerProfileGuard.js.coffee @@ -0,0 +1,57 @@ +$ = jQuery +context = window +context.JK ||= {} + +context.JK.SinglePlayerProfileGuardDialog = class SinglePlayerProfileGuardDialog + constructor: (@app) -> + @rest = context.JK.Rest() + @client = context.jamClient + @logger = context.JK.logger + @gearUtils = context.JK.GearUtilsInstance + @screen = null + @dialogId = 'single-player-profile-dialog'; + @dialog = null; + + initialize:() => + dialogBindings = { + 'beforeShow' : @beforeShow, + 'afterShow' : @afterShow + } + + @dialog = $('[layout-id="' + @dialogId + '"]'); + @app.bindDialog(@dialogId, dialogBindings); + @content = @dialog.find(".dialog-inner") + @audioLatency = @dialog.find('.audio-latency') + @btnPrivateSession = @dialog.find('.btn-private-session') + @btnGearSetup = @dialog.find('.btn-gear-setup') + + @btnPrivateSession.on('click', @onPrivateSessionChoice) + @btnGearSetup.on('click', @onGearSetupChoice) + + beforeShow:() => + @dialog.data('result', { choice: null}) + + + afterShow:() => + canPlayWithOthers = @gearUtils.canPlayWithOthers() + + if canPlayWithOthers.isNoInputProfile + @content.removeClass('high-latency').addClass('has-no-inputs') + else + @content.removeClass('has-no-input').addClass('high-latency') + + latency = '?' + if canPlayWithOthers.audioLatency? + latency = canPlayWithOthers.audioLatency + + @audioLatency.text("#{latency} milliseconds.") + + onPrivateSessionChoice: () => + @dialog.data('result', { choice: 'private_session'}) + @app.layout.closeDialog(@dialogId) + return false + + onGearSetupChoice: () => + @dialog.data('result', { choice: 'gear_setup'}) + @app.layout.closeDialog(@dialogId) + return false \ No newline at end of file diff --git a/web/app/assets/javascripts/everywhere/everywhere.js b/web/app/assets/javascripts/everywhere/everywhere.js index 16adb8b3f..2c010bac2 100644 --- a/web/app/assets/javascripts/everywhere/everywhere.js +++ b/web/app/assets/javascripts/everywhere/everywhere.js @@ -204,8 +204,7 @@ var user = app.user() if(user) { user.done(function(userProfile) { - console.log("app.layout.getCurrentScreen() != 'checkoutOrderScreen'", app.layout.getCurrentScreen()) - if (userProfile.show_whats_next && + if (userProfile.show_whats_next && userProfile.show_whats_next_count < 10 && window.location.pathname.indexOf(gon.client_path) == 0 && window.location.pathname.indexOf('/checkout') == -1 && !app.layout.isDialogShowing('getting-started')) diff --git a/web/app/assets/javascripts/fakeJamClient.js b/web/app/assets/javascripts/fakeJamClient.js index d0b640b8d..732547d19 100644 --- a/web/app/assets/javascripts/fakeJamClient.js +++ b/web/app/assets/javascripts/fakeJamClient.js @@ -25,8 +25,9 @@ var metronomeBPM=false; var metronomeSound=false; var metronomeMeter=0; - var backingTrackPath=""; - var backingTrackLoop=false; + var backingTrackPath = ""; + var backingTrackLoop = false; + var simulateNoInputs = false; function dbg(msg) { logger.debug('FakeJamClient: ' + msg); } @@ -47,7 +48,13 @@ function FTUEPageLeave() {} function FTUECancel() {} function FTUEGetMusicProfileName() { - return "FTUEAttempt-1" + + if(simulateNoInputs) { + return "System Default (Playback Only)" + } + else { + return "FTUEAttempt-1" + } } function FTUESetMusicProfileName() { @@ -266,6 +273,10 @@ return false; } + function FTUECreateUpdatePlayBackProfile() { + return true; + } + function RegisterVolChangeCallBack(functionName) { dbg('RegisterVolChangeCallBack'); } @@ -444,6 +455,10 @@ ]; var response = []; for (var i=0; i'; } + function optionRequiresMultiplayerProfile() { + return createSessionSettings.createType == '<%= MusicSession::CREATE_TYPE_START_SCHEDULED%>' || + createSessionSettings.createType == '<%= MusicSession::CREATE_TYPE_IMMEDIATE %>' || + createSessionSettings.createType == '<%= MusicSession::CREATE_TYPE_RSVP %>' || + createSessionSettings.createType == '<%= MusicSession::CREATE_TYPE_SCHEDULE_FUTURE %>'; + } + function next(event) { if(willOptionStartSession()) { if(!context.JK.guardAgainstBrowser(app)) { @@ -915,6 +924,12 @@ } } + if(optionRequiresMultiplayerProfile()) { + if(context.JK.guardAgainstSinglePlayerProfile(app).canPlay == false) { + return false; + } + } + var valid = beforeMoveStep(); if (!valid) { return false; diff --git a/web/app/assets/javascripts/session.js b/web/app/assets/javascripts/session.js index 2b6ea1e80..e1b191104 100644 --- a/web/app/assets/javascripts/session.js +++ b/web/app/assets/javascripts/session.js @@ -107,6 +107,8 @@ var $screen = null; var $mixModeDropdown = null; var $templateMixerModeChange = null; + + var $myTracksNoTracks = null; var $otherAudioContainer = null; var $myTracksContainer = null; var $liveTracksContainer = null; @@ -120,6 +122,9 @@ var $liveTracks = null; var $audioTracks = null; var $fluidTracks = null; + var $voiceChat = null; + var $openFtue = null; + var $tracksHolder = null; var mediaTrackGroups = [ChannelGroupIds.MediaTrackGroup, ChannelGroupIds.JamTrackGroup, ChannelGroupIds.MetronomeGroup]; var muteBothMasterAndPersonalGroups = [ChannelGroupIds.AudioInputMusicGroup, ChannelGroupIds.MediaTrackGroup, ChannelGroupIds.JamTrackGroup, ChannelGroupIds.MetronomeGroup]; @@ -196,53 +201,85 @@ // body-scoped drag handlers can go active screenActive = true; - gearUtils.guardAgainstInvalidConfiguration(app) - .fail(function() { - promptLeave = false; - window.location = '/client#/home' - }) - .done(function(){ - var result = sessionUtils.SessionPageEnter(); + rest.getSessionHistory(data.id) + .done(function(musicSession) { - gearUtils.guardAgainstActiveProfileMissing(app, result) - .fail(function(data) { + var singlePlayerCheckOK = true; + // to know whether we are allowed to be in this session, we have to check if we are the creator when checking against single player functionality + if(musicSession.user_id != context.JK.currentUserId) { + + var canPlay = context.JK.guardAgainstSinglePlayerProfile(app, function () { promptLeave = false; - if(data && data.reason == 'handled') { - if(data.nav == 'BACK') { - window.history.go(-1); - } - else { - window.location = data.nav; - } - } - else { - window.location = '/client#/home'; - } - }) - .done(function(){ + }); - sessionModel.waitForSessionPageEnterDone() - .done(function(userTracks) { + singlePlayerCheckOK = canPlay.canPlay; + } + if(singlePlayerCheckOK) { - context.JK.CurrentSessionModel.setUserTracks(userTracks); + var shouldVerifyNetwork = musicSession.musician_access; + gearUtils.guardAgainstInvalidConfiguration(app, shouldVerifyNetwork) + .fail(function() { + promptLeave = false; + window.location = '/client#/home' + }) + .done(function(){ + var result = sessionUtils.SessionPageEnter(); - initializeSession(); - }) - .fail(function(data) { - if(data == "timeout") { - context.JK.alertSupportedNeeded('The audio system has not reported your configured tracks in a timely fashion.') - } - else if(data == 'session_over') { - // do nothing; session ended before we got the user track info. just bail - } - else { - contetx.JK.alertSupportedNeeded('Unable to determine configured tracks due to reason: ' + data) - } + gearUtils.guardAgainstActiveProfileMissing(app, result) + .fail(function(data) { + promptLeave = false; + if(data && data.reason == 'handled') { + if(data.nav == 'BACK') { + window.history.go(-1); + } + else { + window.location = data.nav; + } + } + else { + window.location = '/client#/home'; + } + }) + .done(function(){ + + sessionModel.waitForSessionPageEnterDone() + .done(function(userTracks) { + + context.JK.CurrentSessionModel.setUserTracks(userTracks); + + initializeSession(); + }) + .fail(function(data) { + if(data == "timeout") { + context.JK.alertSupportedNeeded('The audio system has not reported your configured tracks in a timely fashion.') + } + else if(data == 'session_over') { + // do nothing; session ended before we got the user track info. just bail + } + else { + context.JK.alertSupportedNeeded('Unable to determine configured tracks due to reason: ' + data) + } + promptLeave = false; + window.location = '/client#/home' + }); + }) + }) + } + else { + if(canPlay.dialog) { + canPlay.dialog.one(EVENTS.DIALOG_CLOSED, function(e, data) { + if(data.canceled) { promptLeave = false; window.location = '/client#/home' - }); - }) + } + }) + } + } }) + .fail(function() { + + }) + } function notifyWithUserInfo(title , text, clientId) { @@ -635,7 +672,6 @@ function renderSession() { $myTracksContainer.empty(); $('.session-track').remove(); // Remove previous tracks - var $voiceChat = $('#voice-chat'); $voiceChat.hide(); _updateMixers(); _renderTracks(); @@ -933,7 +969,6 @@ if(voiceChatMixers) { var mixer = voiceChatMixers.mixer; - var $voiceChat = $('#voice-chat'); $voiceChat.show(); $voiceChat.attr('mixer-id', mixer.id); var $voiceChatGain = $voiceChat.find('.voicechat-gain'); @@ -1654,79 +1689,87 @@ var myTrack = app.clientId == participant.client_id; + // special case; if it's me and I have no tracks, show info about this sort of use of the app + if (myTrack && participant.tracks.length == 0) { + $tracksHolder.addClass('no-local-tracks') + } + else { + $tracksHolder.removeClass('no-local-tracks') + } + // loop through all tracks for each participant - $.each(participant.tracks, function(index, track) { - var instrumentIcon = context.JK.getInstrumentIcon45(track.instrument_id); - var photoUrl = context.JK.resolveAvatarUrl(participant.user.photo_url); + $.each(participant.tracks, function (index, track) { + var instrumentIcon = context.JK.getInstrumentIcon45(track.instrument_id); + var photoUrl = context.JK.resolveAvatarUrl(participant.user.photo_url); - // Default trackData to participant + no Mixer state. - var trackData = { - trackId: track.id, - connection_id: track.connection_id, - client_track_id: track.client_track_id, - client_resource_id: track.client_resource_id, - clientId: participant.client_id, - name: name, - instrumentIcon: instrumentIcon, - avatar: photoUrl, - latency: "good", - gainPercent: 0, - muteClass: 'muted', - mixerId: "", - avatarClass: 'avatar-med', - preMasteredClass: "", - myTrack: myTrack - }; + // Default trackData to participant + no Mixer state. + var trackData = { + trackId: track.id, + connection_id: track.connection_id, + client_track_id: track.client_track_id, + client_resource_id: track.client_resource_id, + clientId: participant.client_id, + name: name, + instrumentIcon: instrumentIcon, + avatar: photoUrl, + latency: "good", + gainPercent: 0, + muteClass: 'muted', + mixerId: "", + avatarClass: 'avatar-med', + preMasteredClass: "", + myTrack: myTrack + }; - var mixerData = findMixerForTrack(participant.client_id, track, myTrack) - var mixer = mixerData.mixer; - var vuMixer = mixerData.vuMixer; - var muteMixer = mixerData.muteMixer; - var oppositeMixer = mixerData.oppositeMixer; + var mixerData = findMixerForTrack(participant.client_id, track, myTrack) + var mixer = mixerData.mixer; + var vuMixer = mixerData.vuMixer; + var muteMixer = mixerData.muteMixer; + var oppositeMixer = mixerData.oppositeMixer; - - if (mixer && oppositeMixer) { - myTrack = (mixer.group_id === ChannelGroupIds.AudioInputMusicGroup); - if(!myTrack) { - // it only makes sense to track 'audio established' for tracks that don't belong to you - sessionModel.setAudioEstablished(participant.client_id, true); - } - - var gainPercent = percentFromMixerValue( - mixer.range_low, mixer.range_high, mixer.volume_left); - var muteClass = "enabled"; - if (mixer.mute) { - muteClass = "muted"; - } - - trackData.gainPercent = gainPercent; - trackData.muteClass = muteClass; - trackData.mixerId = mixer.id; - trackData.vuMixerId = vuMixer.id; - trackData.oppositeMixer = oppositeMixer; - trackData.muteMixerId = muteMixer.id; - trackData.noaudio = false; - trackData.group_id = mixer.group_id; - context.jamClient.SessionSetUserName(participant.client_id,name); - - } else { // No mixer to match, yet - lookingForMixers.push({track: track, clientId: participant.client_id}) - trackData.noaudio = true; - if (!(lookingForMixersTimer)) { - logger.debug("waiting for mixer to show up for track: " + track.id) - lookingForMixersTimer = context.setInterval(lookForMixers, 500); - } + if (mixer && oppositeMixer) { + myTrack = (mixer.group_id === ChannelGroupIds.AudioInputMusicGroup); + if (!myTrack) { + // it only makes sense to track 'audio established' for tracks that don't belong to you + sessionModel.setAudioEstablished(participant.client_id, true); } - var allowDelete = myTrack && index > 0; - _addTrack(allowDelete, trackData, mixer, oppositeMixer); - - // Show settings icons only for my tracks - if (myTrack) { - myTracks.push(trackData); + var gainPercent = percentFromMixerValue( + mixer.range_low, mixer.range_high, mixer.volume_left); + var muteClass = "enabled"; + if (mixer.mute) { + muteClass = "muted"; } + + trackData.gainPercent = gainPercent; + trackData.muteClass = muteClass; + trackData.mixerId = mixer.id; + trackData.vuMixerId = vuMixer.id; + trackData.oppositeMixer = oppositeMixer; + trackData.muteMixerId = muteMixer.id; + trackData.noaudio = false; + trackData.group_id = mixer.group_id; + context.jamClient.SessionSetUserName(participant.client_id, name); + + } else { // No mixer to match, yet + lookingForMixers.push({track: track, clientId: participant.client_id}) + trackData.noaudio = true; + if (!(lookingForMixersTimer)) { + logger.debug("waiting for mixer to show up for track: " + track.id) + lookingForMixersTimer = context.setInterval(lookForMixers, 500); + } + } + + var allowDelete = myTrack && index > 0; + _addTrack(allowDelete, trackData, mixer, oppositeMixer); + + // Show settings icons only for my tracks + if (myTrack) { + myTracks.push(trackData); + } }); + }); configureTrackDialog = new context.JK.ConfigureTrackDialog(app, myTracks, sessionId, sessionModel); @@ -1879,14 +1922,14 @@ if (!(mixer.stereo)) { // mono track if (mixerId.substr(-4) === "_vul") { // Do the left - selector = $('#tracks [mixer-id="' + pureMixerId + '_vul"]'); + selector = $tracksHolder.find('[mixer-id="' + pureMixerId + '_vul"]'); context.JK.VuHelpers.updateVU(selector, value); // Do the right - selector = $('#tracks [mixer-id="' + pureMixerId + '_vur"]'); + selector = $tracksHolder.find('[mixer-id="' + pureMixerId + '_vur"]'); context.JK.VuHelpers.updateVU(selector, value); } // otherwise, it's a mono track, _vur event - ignore. } else { // stereo track - selector = $('#tracks [mixer-id="' + mixerId + '"]'); + selector = $tracksHolder.find('[mixer-id="' + mixerId + '"]'); context.JK.VuHelpers.updateVU(selector, value); } } @@ -3025,11 +3068,16 @@ return true; } + function showFTUEWhenNoInputs( ) { + //app.afterFtue = function() { window.location.reload }; + app.layout.startNewFtue(); + } + function events() { $('#session-leave').on('click', sessionLeave); $('#session-resync').on('click', sessionResync); $('#session-contents').on("click", '[action="delete"]', deleteSession); - $('#tracks').on('click', 'div[control="mute"]', toggleMute); + $tracksHolder.on('click', 'div[control="mute"]', toggleMute); $('#recording-start-stop').on('click', startStopRecording); $('#open-a-recording').on('click', openRecording); $('#open-a-jamtrack').on('click', openJamTrack); @@ -3038,11 +3086,24 @@ $('#session-invite-musicians').on('click', inviteMusicians); $('#session-invite-musicians2').on('click', inviteMusicians); $('#track-settings').click(function() { + + if(gearUtils.isNoInputProfile()) { + // show FTUE + showFTUEWhenNoInputs(); + return false; + } + else { configureTrackDialog.refresh(); configureTrackDialog.showVoiceChatPanel(true); configureTrackDialog.showMusicAudioPanel(true); + } }); + $openFtue.click(function() { + showFTUEWhenNoInputs(); + return false; + }) + $closePlaybackRecording.on('click', closeOpenMedia); $(playbackControls) .on('pause', onPause) @@ -3083,6 +3144,8 @@ $mixModeDropdown = $screen.find('select.monitor-mode') $templateMixerModeChange = $('#template-mixer-mode-change'); $otherAudioContainer = $('#session-recordedtracks-container'); + $myTracksNoTracks = $('#session-mytracks-notracks') + $openFtue = $screen.find('.open-ftue-no-tracks') $myTracksContainer = $('#session-mytracks-container') $liveTracksContainer = $('#session-livetracks-container'); $closePlaybackRecording = $('#close-playback-recording') @@ -3093,7 +3156,9 @@ $myTracks = $screen.find('.session-mytracks'); $liveTracks = $screen.find('.session-livetracks'); $audioTracks = $screen.find('.session-recordings'); - $fluidTracks = $screen.find('.session-fluidtracks') + $fluidTracks = $screen.find('.session-fluidtracks'); + $voiceChat = $screen.find('#voice-chat'); + $tracksHolder = $screen.find('#tracks') events(); diff --git a/web/app/assets/javascripts/sessionModel.js b/web/app/assets/javascripts/sessionModel.js index 05f726557..3c0afa636 100644 --- a/web/app/assets/javascripts/sessionModel.js +++ b/web/app/assets/javascripts/sessionModel.js @@ -12,6 +12,7 @@ var ALERT_TYPES = context.JK.ALERT_TYPES; var EVENTS = context.JK.EVENTS; var MIX_MODES = context.JK.MIX_MODES; + var gearUtils = context.JK.GearUtilsInstance; var userTracks = null; // comes from the backend var clientId = client.clientID; @@ -213,7 +214,8 @@ // see if we already have tracks; if so, we need to run with these var inputTracks = context.JK.TrackHelpers.getUserTracks(context.jamClient); - if(inputTracks.length > 0) { + + if(inputTracks.length > 0 || gearUtils.isNoInputProfile() ) { logger.debug("on page enter, tracks are already available") sessionPageEnterDeferred.resolve(inputTracks); var deferred = sessionPageEnterDeferred; diff --git a/web/app/assets/javascripts/session_utils.js b/web/app/assets/javascripts/session_utils.js index d1e2358f7..4e3b13594 100644 --- a/web/app/assets/javascripts/session_utils.js +++ b/web/app/assets/javascripts/session_utils.js @@ -136,18 +136,20 @@ return false; } - gearUtils.guardAgainstInvalidConfiguration(app) - .fail(function() { - app.notify( - { title: "Unable to Join Session", - text: "You can only join a session once you have working audio gear and a tested internet connection." - }); - }) - .done(function() { - if (successCallback) { - successCallback(); - } - }); + if(context.JK.guardAgainstSinglePlayerProfile(app).canPlay) { + gearUtils.guardAgainstInvalidConfiguration(app) + .fail(function() { + app.notify( + { title: "Unable to Join Session", + text: "You can only join a session once you have working audio gear and a tested internet connection." + }); + }) + .done(function() { + if (successCallback) { + successCallback(); + } + }); + } } sessionUtils.joinSession = function(sessionId) { diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index 33ba2dd4e..3fb56cc43 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -1111,7 +1111,7 @@ context.JK.guardAgainstBrowser = function(app, args) { if(!gon.isNativeClient) { - logger.debug("guarding against normal browser on screen thaht requires native client") + logger.debug("guarding against normal browser on screen that requires native client") app.layout.showDialog('launch-app-dialog', args) .one(EVENTS.DIALOG_CLOSED, function() { if(args && args.goHome) { @@ -1124,6 +1124,111 @@ return true; } + context.JK.guardAgainstSinglePlayerProfile = function(app, beforeCallback) { + + var canPlayWithOthers = context.JK.GearUtilsInstance.canPlayWithOthers(); + + if(!canPlayWithOthers.canPlay) { + logger.debug("guarding against single player profile") + + var $dialog = app.layout.showDialog('single-player-profile-dialog'); + + // so that callers can check dialog result + canPlayWithOthers.dialog = $dialog; + + // allow callers to take action before default behavior + if(beforeCallback) { + $dialog.one(EVENTS.DIALOG_CLOSED, beforeCallback); + } + + $dialog.one(EVENTS.DIALOG_CLOSED, function(e, data) { + + if(!data.canceled) { + if(data.result.choice == 'private_session') { + var data = { + createType: 'quick-start', + timezone: {}, + recurring_mode: {}, + language: {}, + band: {}, + musician_access: {}, + fans_access: {}, + rsvp_slots: [], + open_rsvps: false + }; + + context.JK.privateSessionSettings(data) + + context.JK.createSession(app, data) + .done(function(response) { + var sessionId = response.id; + + context.JK.GA.trackSessionCount(true, true, 0); + + // we redirect to the session screen, which handles the REST call to POST /participants. + logger.debug("joining session screen: " + sessionId) + context.location = '/client#/session/' + sessionId; + }) + .fail(function(jqXHR) { + logger.debug("unable to schedule a private session") + app.notifyServerError(jqXHR, "Unable to schedule a private session"); + }) + } + else if(data.result.choice == 'gear_setup') { + window.location = '/client#/account/audio' + } + else + { + logger.error("unknown choice: " + data.result.choice) + alert("unknown choice: " + data.result.choice) + } + } + }) + } + + return canPlayWithOthers; + } + + context.JK.createSession = function(app, data) { + + // auto pick an 'other' instrument + var otherId = context.JK.server_to_client_instrument_map.Other.server_id; // get server ID + var otherInstrumentInfo = context.JK.instrument_id_to_instrument[otherId]; // get display name + var beginnerLevel = 1; // default to beginner + var instruments = [ {id: otherId, name: otherInstrumentInfo.display, level: beginnerLevel} ]; + $.each(instruments, function(index, instrument) { + var slot = {}; + slot.instrument_id = instrument.id; + slot.proficiency_level = instrument.level; + slot.approve = true; + data.rsvp_slots.push(slot); + }); + + data.isUnstructuredRsvp = true; + + return rest.createScheduledSession(data) + } + + context.JK.privateSessionSettings = function(createSessionSettings) { + createSessionSettings.genresValues = ['Pop']; + createSessionSettings.genres = ['pop']; + createSessionSettings.timezone = 'Central Time (US & Canada),America/Chicago' + createSessionSettings.name = "Private Test Session"; + createSessionSettings.description = "Private session set up just to test things out in the session interface by myself."; + createSessionSettings.notations = []; + createSessionSettings.language = 'eng' + createSessionSettings.legal_policy = 'Standard'; + createSessionSettings.musician_access = false + createSessionSettings.fan_access = false + createSessionSettings.fan_chat = false + createSessionSettings.approval_required = false + createSessionSettings.legal_terms = true + createSessionSettings.recurring_mode = 'once'; + createSessionSettings.start = new Date().toDateString() + ' ' + context.JK.formatUtcTime(new Date(), false); + createSessionSettings.duration = "60"; + createSessionSettings.open_rsvps = false + createSessionSettings.rsvp_slots = []; + } /* * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message * Digest Algorithm, as defined in RFC 1321. diff --git a/web/app/assets/javascripts/wizard/gear/gear_wizard.js b/web/app/assets/javascripts/wizard/gear/gear_wizard.js index cb0cd4317..218ad8b8f 100644 --- a/web/app/assets/javascripts/wizard/gear/gear_wizard.js +++ b/web/app/assets/javascripts/wizard/gear/gear_wizard.js @@ -176,6 +176,10 @@ wizard.setBackState(enabled); } + function moveToNext() { + wizard.moveToNext(); + } + function setChosenInputs(_inputs) { inputs = _inputs; } @@ -222,6 +226,7 @@ this.getChosenInputs = getChosenInputs; this.setNextState = setNextState; this.setBackState = setBackState; + this.moveToNext = moveToNext; this.initialize = initialize; this.createFTUEProfile = createFTUEProfile; this.getWizard = function() {return wizard; } diff --git a/web/app/assets/javascripts/wizard/gear/step_network_test.js b/web/app/assets/javascripts/wizard/gear/step_network_test.js index 008c2409a..f2b019b14 100644 --- a/web/app/assets/javascripts/wizard/gear/step_network_test.js +++ b/web/app/assets/javascripts/wizard/gear/step_network_test.js @@ -9,7 +9,8 @@ var logger = context.JK.logger; var networkTest = new context.JK.NetworkTest(app); var $step = null; - + // if not null and with in say 5 seconds, then the user is 'NEXT'ing too quickly. slow them down + var clickFastTime = null; function getLastNetworkFailAnalytics() { return networkTest.getLastNetworkFailure(); @@ -36,13 +37,29 @@ initializeBackButtonState(); } function initializeNextButtonState() { - dialog.setNextState(networkTest.hasScoredNetworkSuccessfully() && !networkTest.isScoring()); + dialog.setNextState(!networkTest.isScoring()); } function initializeBackButtonState() { dialog.setBackState(!networkTest.isScoring()); } + function handleNext() { + // if we don't have a valid score, and if it's been less than 5 seconds since we've shown this step, slow the user down + if (context.jamClient.GetNetworkTestScore() < 1 && userIsFastNexting()) { + context.JK.Banner.showYesNo({ + html: "By clicking NEXT and skipping the test, you won't be able to play online in real-time sessions with others. Is this OK?", + yes: function() { + dialog.moveToNext(); + }}); + + return false; + } + else { + return true; + } + } + function handleHelp() { return "https://jamkazam.desk.com/customer/portal/articles/1716139-what-to-do-if-you-cannot-pass-the-network-test" //return "https://jamkazam.desk.com/customer/portal/articles/1599969-first-time-setup---step-6---test-your-network"; @@ -57,6 +74,16 @@ networkTest.haltScoring(); networkTest.cancel(); updateButtons(); + watchForFastNexting(); + } + + // fast nexting is a someone hitting next very quickly + function watchForFastNexting() { + clickFastTime = new Date(); + } + + function userIsFastNexting() { + return new Date().getTime() - clickFastTime.getTime() < 5000 } function beforeHide() { @@ -77,6 +104,7 @@ this.handleHelp = handleHelp; this.newSession = newSession; this.beforeHide = beforeHide; + this.handleNext = handleNext; this.beforeShow = beforeShow; this.initialize = initialize; this.getLastNetworkFailAnalytics = getLastNetworkFailAnalytics; diff --git a/web/app/assets/javascripts/wizard/gear/step_select_gear.js b/web/app/assets/javascripts/wizard/gear/step_select_gear.js index 9479f86c9..bd67be896 100644 --- a/web/app/assets/javascripts/wizard/gear/step_select_gear.js +++ b/web/app/assets/javascripts/wizard/gear/step_select_gear.js @@ -46,6 +46,8 @@ var $templateDeviceNotValid = null; var $resyncStatus = null; var $resyncStatusText = null; + var $latencyScoreBox = null; + var $highLatencyNotice = null; var operatingSystem = null; @@ -579,6 +581,11 @@ function initializeResync() { $resyncBtn.unbind('click').click(function () { + if($highLatencyNotice) { + $highLatencyNotice.btOff() + $highLatencyNotice = null; + } + scheduleRescanSystem(function() { if (getSelectedInputs().length > 0 && getSelectedOutputs().length == 2) { logger.debug("after rescan, ready to attempt score") @@ -946,6 +953,21 @@ queueUpdateDeviceList = false; updateDeviceList(); } + + if(!data.validLatencyScore) { + if (selectedDeviceInfo.input.info.type.indexOf('Win32_asio') > -1) { + prodUserAboutHighLatency($latencyScoreBox, 'asio') + } + else if (selectedDeviceInfo.output.info.type.indexOf('Win32_asio') > -1) { + prodUserAboutHighLatency($latencyScoreBox, 'asio') + } + else if (selectedDeviceInfo.input.info.type == 'MacOSX_builtin' || selectedDeviceInfo.output.info.type == 'MacOSX_builtin') { + prodUserAboutHighLatency($latencyScoreBox, 'macosx-builtin') + } + else { + prodUserAboutHighLatency($latencyScoreBox, 'generic') + } + } } function getLastAudioTestFailAnalytics() { @@ -962,6 +984,13 @@ } } + function prodUserAboutHighLatency($btn, additional) { + + setTimeout(function() { + $highLatencyNotice = context.JK.prodBubble($btn, 'high-latency-notice', {additional: additional}, {duration: 20000, width:'400px', positions:['top']}); + }, 300) + } + function prodUserToTweakASIOSettings($btn) { setTimeout(function() { context.JK.prodBubble($btn, 'tweak-asio-settings', {}, {positions:['top']}); @@ -972,19 +1001,11 @@ renderScoringStopped(); gearUtils.postDiagnostic(operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, true); - if(data.reason == "latency") { + if(data.reason == "io") { - console.log("selectedDeviceInfo", selectedDeviceInfo) - if(selectedDeviceInfo.input.info.type.indexOf('Win32_asio') > -1) { - prodUserToTweakASIOSettings($asioInputControlBtn) - } - else if(selectedDeviceInfo.output.info.type.indexOf('Win32_asio') > -1) { - prodUserToTweakASIOSettings($asioOutputControlBtn) - } - storeLastFailureForAnalytics(context.JK.detectOS(), context.JK.GA.AudioTestFailReasons.latency, data.latencyScore); - } - else if(data.reason = "io") { + //storeLastFailureForAnalytics(context.JK.detectOS(), context.JK.GA.AudioTestFailReasons.latency, data.latencyScore); + if(data.ioTarget == 'bad') { storeLastFailureForAnalytics(context.JK.detectOS(), context.JK.GA.AudioTestFailReasons.ioTarget, data.ioTargetScore); } @@ -1210,6 +1231,7 @@ $instructions = $step.find('.instructions'); $resyncStatus = $step.find('.resync-status'); $resyncStatusText = $step.find('.resynctext'); + $latencyScoreBox = $step.find('.latency-score-section') operatingSystem = context.JK.GetOSAsString(); frameBuffers.initialize($knobs); $(frameBuffers) diff --git a/web/app/assets/javascripts/wizard/gear_test.js b/web/app/assets/javascripts/wizard/gear_test.js index fc397c2f9..7e7f2b352 100644 --- a/web/app/assets/javascripts/wizard/gear_test.js +++ b/web/app/assets/javascripts/wizard/gear_test.js @@ -52,7 +52,7 @@ var GEAR_TEST_INVALIDATED_ASYNC = "gear_test.async_invalidated"; // happens when backend alerts us device is invalid function isGoodFtue() { - return validLatencyScore && validIOScore && !asynchronousInvalidDevice; + return validIOScore && !asynchronousInvalidDevice; } function processIOScore(io) { @@ -90,7 +90,7 @@ // now base the overall IO score based on both values. - $self.triggerHandler(GEAR_TEST_IO_DONE, {std:std, median:median, io:io, aggregrateIOClass: aggregrateIOClass, medianIOClass : medianIOClass, stdIOClass: stdIOClass}) + $self.triggerHandler(GEAR_TEST_IO_DONE, {std:std, median:median, io:io, aggregrateIOClass: aggregrateIOClass, medianIOClass : medianIOClass, stdIOClass: stdIOClass, validLatencyScore: validLatencyScore}) //renderIOScore(std, median, io, aggregrateIOClass, medianIOClass, stdIOClass); if(aggregrateIOClass == "bad") { @@ -103,10 +103,10 @@ scoring = false; if(isGoodFtue()) { - $self.triggerHandler(GEAR_TEST_DONE) + $self.triggerHandler(GEAR_TEST_DONE, {validLatencyScore: validLatencyScore}) } else { - $self.triggerHandler(GEAR_TEST_FAIL, {reason:'io', ioTarget: medianIOClass, ioTargetScore: median, ioVariance: stdIOClass, ioVarianceScore: std}); + $self.triggerHandler(GEAR_TEST_FAIL, {reason:'io', ioTarget: medianIOClass, ioTargetScore: median, ioVariance: stdIOClass, ioVarianceScore: std, validLatencyScore: validLatencyScore}); } } @@ -182,11 +182,10 @@ updateScoreReport(latency, refocused); - // if there was a valid latency score, go on to the next step - if (validLatencyScore) { + if (true || validLatencyScore) { $self.triggerHandler(GEAR_TEST_IO_START); // reuse valid IO score if this is on refocus - if(refocused && validIOScore) { + if(false && (refocused && validIOScore)) { processIOScore(ioScore); } else { @@ -215,12 +214,12 @@ } else { scoring = false; - $self.triggerHandler(GEAR_TEST_FAIL, {reason:'latency', latencyScore: latencyScore.latency}) + $self.triggerHandler(GEAR_TEST_FAIL, {reason:'latency', validLatencyScore: validLatencyScore, latencyScore: latencyScore.latency}) } }) .fail(function(ftueSaveResult) { scoring = false; - $self.triggerHandler(GEAR_TEST_FAIL, {reason:'invalid_configuration', data: ftueSaveResult}) + $self.triggerHandler(GEAR_TEST_FAIL, {reason:'invalid_configuration', validLatencyScore: validLatencyScore, data: ftueSaveResult}) }) }, 250); } diff --git a/web/app/assets/javascripts/wizard/gear_utils.js b/web/app/assets/javascripts/wizard/gear_utils.js index c9d67f533..04716dde1 100644 --- a/web/app/assets/javascripts/wizard/gear_utils.js +++ b/web/app/assets/javascripts/wizard/gear_utils.js @@ -14,6 +14,8 @@ var VOICE_CHAT = context.JK.VOICE_CHAT; var AUDIO_DEVICE_BEHAVIOR = context.JK.AUDIO_DEVICE_BEHAVIOR; var EVENTS = context.JK.EVENTS; + var SYSTEM_DEFAULT_PLAYBACK_ONLY = 'System Default (Playback Only)'; + context.JK.GearUtilsInstance = gearUtils; @@ -28,12 +30,42 @@ return channel.assignment == ASSIGNMENT.CHAT || channel.assignment == ASSIGNMENT.OUTPUT || channel.assignment > 0; } + // to play with others, you have to have inputs, + // as well have a score below 20 ms + gearUtils.canPlayWithOthers = function(profile) { - gearUtils.createProfileName = function(deviceInfo, chatName) { + var isNoInputProfile = gearUtils.isNoInputProfile(profile); + var expectedLatency = context.jamClient.FTUEGetExpectedLatency(); + var audioLatency = expectedLatency ? expectedLatency.latency : null; + var highLatency = audioLatency > 20; + var networkScore = context.jamClient.GetNetworkTestScore(); + var badNetworkScore = networkScore < 2; + + return { + canPlay: !isNoInputProfile && !highLatency, + isNoInputProfile: isNoInputProfile, + badNetworkScore: badNetworkScore, + highLatency: highLatency, + audioLatency: audioLatency, + networkScore: networkScore, + } + } + + gearUtils.isNoInputProfile = function(profile) { + if (profile === undefined) { + profile = context.jamClient.FTUEGetMusicProfileName(); + } + + if(profile == SYSTEM_DEFAULT_PLAYBACK_ONLY) { + return true; + } + } + + gearUtils.createProfileName = function (deviceInfo, chatName) { var isSameInOut = deviceInfo.input.id == deviceInfo.output.id; var name = null; - if(isSameInOut) { + if (isSameInOut) { name = "In/Out: " + deviceInfo.input.info.displayName; } else { @@ -45,19 +77,19 @@ } - gearUtils.selectedDeviceInfo = function(audioInputDeviceId, audioOutputDeviceId, deviceInformation) { + gearUtils.selectedDeviceInfo = function (audioInputDeviceId, audioOutputDeviceId, deviceInformation) { - if(!audioInputDeviceId) { + if (!audioInputDeviceId) { logger.debug("gearUtils.selectedDeviceInfo: no active input device"); return null; } - if(!audioOutputDeviceId) { + if (!audioOutputDeviceId) { logger.debug("gearUtils.selectedDeviceInfo: no active output device"); return null; } - if(!deviceInformation) { + if (!deviceInformation) { deviceInformation = gearUtils.loadDeviceInfo(); } @@ -81,7 +113,7 @@ } } - gearUtils.loadDeviceInfo = function() { + gearUtils.loadDeviceInfo = function () { var operatingSystem = context.JK.GetOSAsString(); // should return one of: @@ -128,6 +160,10 @@ return; } + if (device.name == "JamKazam Virtual Input") { + return; + } + var deviceInfo = {}; deviceInfo.id = device.guid; @@ -145,22 +181,22 @@ return loadedDevices; } - gearUtils.updateDefaultBuffers = function(selectedDeviceInfo, frameBuffers) { + gearUtils.updateDefaultBuffers = function (selectedDeviceInfo, frameBuffers) { function hasWDMAssociated() { - return selectedDeviceInfo && (selectedDeviceInfo.input.info.type == 'Win32_wdm' || selectedDeviceInfo.output.info.type == 'Win32_wdm') + return selectedDeviceInfo && (selectedDeviceInfo.input.info.type == 'Win32_wdm' || selectedDeviceInfo.output.info.type == 'Win32_wdm') } function hasASIOAssociated() { - return selectedDeviceInfo && (selectedDeviceInfo.input.info.type == 'Win32_asio' || selectedDeviceInfo.output.info.type == 'Win32_asio') + return selectedDeviceInfo && (selectedDeviceInfo.input.info.type == 'Win32_asio' || selectedDeviceInfo.output.info.type == 'Win32_asio') } // handle specific framesize settings - if(hasWDMAssociated() || hasASIOAssociated()) { + if (hasWDMAssociated() || hasASIOAssociated()) { var framesize = frameBuffers.selectedFramesize(); - if(framesize == 2.5) { + if (framesize == 2.5) { // if there is a WDM device, start off at 1/1 due to empirically observed issues with 0/0 - if(hasWDMAssociated()) { + if (hasWDMAssociated()) { logger.debug("setting default buffers to 1/1"); frameBuffers.setBufferIn('1'); frameBuffers.setBufferOut('1'); @@ -172,7 +208,7 @@ frameBuffers.setBufferOut('0'); } } - else if(framesize == 5) { + else if (framesize == 5) { logger.debug("setting default buffers to 3/2"); frameBuffers.setBufferIn('3'); frameBuffers.setBufferOut('2'); @@ -193,7 +229,7 @@ context.jamClient.FTUESetOutputLatency(frameBuffers.selectedBufferOut()); } - gearUtils.ftueSummary = function(operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, isAutomated) { + gearUtils.ftueSummary = function (operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, isAutomated) { return { os: operatingSystem, version: context.jamClient.ClientUpdateVersion(), @@ -203,7 +239,7 @@ validLatencyScore: gearTest.isValidLatencyScore(), validIOScore: gearTest.isValidIOScore(), latencyScore: gearTest.getLatencyScore(), - ioScore : gearTest.getIOScore(), + ioScore: gearTest.getIOScore(), }, audioParameters: { frameSize: frameBuffers.selectedFramesize(), @@ -221,21 +257,21 @@ * This is to provide a unified view of FTUEGetAllAudioConfigurations & FTUEGetGoodAudioConfigurations * @returns an array of profiles, where each profile is: {id: profile-name, good: boolean, class: 'bad' | 'good', current: boolean } */ - gearUtils.getProfiles = function() { + gearUtils.getProfiles = function () { var all = context.jamClient.FTUEGetAllAudioConfigurations(); var good = context.jamClient.FTUEGetGoodAudioConfigurations(); var current = context.jamClient.LastUsedProfileName(); var profiles = []; - context._.each(all, function(item) { + context._.each(all, function (item) { - profiles.push({id: item, good: false, class:'bad', current: current == item}) + profiles.push({id: item, good: false, class: 'bad', current: current == item}) }); - if(good) { - for(var i = 0; i < good.length; i++) { - for(var j = 0; j < profiles.length; j++) { - if(good[i] == profiles[j].id) { + if (good) { + for (var i = 0; i < good.length; i++) { + for (var j = 0; j < profiles.length; j++) { + if (good[i] == profiles[j].id) { profiles[j].good = true; profiles[j].class = 'good'; break; @@ -246,21 +282,21 @@ return profiles; } - gearUtils.postDiagnostic = function(operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, isAutomated) { + gearUtils.postDiagnostic = function (operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, isAutomated) { rest.createDiagnostic({ type: 'GEAR_SELECTION', data: { client_type: context.JK.clientType(), - client_id: - context.JK.JamServer.clientID, - summary:gearUtils.ftueSummary(operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, isAutomated)} + client_id: context.JK.JamServer.clientID, + summary: gearUtils.ftueSummary(operatingSystem, deviceInformation, selectedDeviceInfo, gearTest, frameBuffers, isAutomated) + } }); } // complete list of possibly chatInputs, whether currently assigned as the chat channel or not // each item should be {id: channelId, name: channelName, assignment: channel assignment} - gearUtils.getChatInputs = function(){ + gearUtils.getChatInputs = function () { var musicPorts = jamClient.FTUEGetChannels(); //var chatsOnCurrentDevice = context.jamClient.FTUEGetChatInputs(true); @@ -273,11 +309,11 @@ var deDupper = {}; - context._.each(musicPorts.inputs, function(input) { + context._.each(musicPorts.inputs, function (input) { - var chatInput = {id: input.id, name: input.name, assignment:input.assignment}; - if(!deDupper[input.id]) { - if(input.assignment <= 0) { + var chatInput = {id: input.id, name: input.name, assignment: input.assignment}; + if (!deDupper[input.id]) { + if (input.assignment <= 0) { chatInputs.push(chatInput); deDupper[input.id] = chatInput; } @@ -295,9 +331,9 @@ } })*/ - context._.each(chatsOnOtherDevices, function(chatChannelName, chatChannelId) { + context._.each(chatsOnOtherDevices, function (chatChannelName, chatChannelId) { var chatInput = {id: chatChannelId, name: chatChannelName, assignment: null}; - if(!deDupper[chatInput.id]) { + if (!deDupper[chatInput.id]) { var assignment = context.jamClient.TrackGetAssignment(chatChannelId, true); chatInput.assignment = assignment; @@ -309,11 +345,11 @@ return chatInputs; } - gearUtils.isChannelAvailableForChat = function(chatChannelId, musicPorts) { + gearUtils.isChannelAvailableForChat = function (chatChannelId, musicPorts) { var result = true; - context._.each(musicPorts.inputs, function(inputChannel) { + context._.each(musicPorts.inputs, function (inputChannel) { // if the channel is currently assigned to a track, it not unassigned - if(inputChannel.id == chatChannelId && (inputChannel.assignment > 0)) { + if (inputChannel.id == chatChannelId && (inputChannel.assignment > 0)) { result = false; return false; // break } @@ -324,13 +360,13 @@ // if the user has a good user network score, immediately returns with a resolved deferred object. // if not, the user will have the network test dialog prompted... once it's closed, then you'll be told reject() if score is still bad, or resolve() if now good - gearUtils.guardAgainstBadNetworkScore = function(app) { + gearUtils.guardAgainstBadNetworkScore = function (app) { var deferred = new $.Deferred(); if (!gearUtils.validNetworkScore()) { // invalid network test score. They have to score to move on - app.layout.showDialog('network-test').one(EVENTS.DIALOG_CLOSED, function() { - if(gearUtils.validNetworkScore()) { + app.layout.showDialog('network-test').one(EVENTS.DIALOG_CLOSED, function () { + if (gearUtils.validNetworkScore()) { deferred.resolve(); } else { @@ -346,19 +382,19 @@ // XXX this isn't quite right... it needs to check if a good device is *active* // but seen too many problems so far with the backend not reporting any profile active - gearUtils.hasGoodActiveProfile = function(verifyTracks) { + gearUtils.hasGoodActiveProfile = function (verifyTracks) { var hasOneConfigureDevice = context.JK.hasOneConfiguredDevice(); logger.debug("hasGoodActiveProfile: " + hasOneConfigureDevice ? "devices='has at least one configured device' " : "devices='has no configured device' ") return hasOneConfigureDevice; } // if the user does not have any profiles, show the FTUE - gearUtils.guardAgainstInvalidGearConfiguration = function(app) { + gearUtils.guardAgainstInvalidGearConfiguration = function (app) { var deferred = new $.Deferred(); if (context.jamClient.FTUEGetAllAudioConfigurations().length == 0) { - app.layout.showDialog('gear-wizard').one(EVENTS.DIALOG_CLOSED, function() { - if(gearUtils.hasGoodActiveProfile() && gearUtils.validNetworkScore()) { + app.layout.showDialog('gear-wizard').one(EVENTS.DIALOG_CLOSED, function () { + if (gearUtils.hasGoodActiveProfile() && gearUtils.validNetworkScore()) { deferred.resolve(); } else { @@ -373,27 +409,27 @@ return deferred; } - gearUtils.guardAgainstActiveProfileMissing = function(app, backendInfo) { + gearUtils.guardAgainstActiveProfileMissing = function (app, backendInfo) { var deferred = new $.Deferred(); logger.debug("guardAgainstActiveProfileMissing: backendInfo %o", backendInfo); - if(backendInfo.error && backendInfo['reason'] == 'no_profile' && context.jamClient.FTUEGetAllAudioConfigurations().length > 0) { + if (backendInfo.error && backendInfo['reason'] == 'no_profile' && context.jamClient.FTUEGetAllAudioConfigurations().length > 0) { // if the backend says we have no_profile, but we have profiles , send them to the audio profile screen // this should be a very rare path - deferred.reject({reason:'handled', nav: '/client#/account/audio'}); + deferred.reject({reason: 'handled', nav: '/client#/account/audio'}); context.JK.Banner.showAlert('No Active Profile', 'We\'ve sent you to the audio profile screen to remedy the fact that you have no active audio profile. Please select ACTIVATE on an existing profile, or select ADD NEW GEAR to add a new profile.'); } else if (backendInfo.error && backendInfo['reason'] == 'device_failure') { app.layout.showDialog('audio-profile-invalid-dialog') - .one(EVENTS.DIALOG_CLOSED, function(e, data) { - if(!data.result || data.result == 'cancel') { - deferred.reject({reason:'handled', nav: 'BACK'}); + .one(EVENTS.DIALOG_CLOSED, function (e, data) { + if (!data.result || data.result == 'cancel') { + deferred.reject({reason: 'handled', nav: 'BACK'}); } - else if(data.result == 'configure_gear'){ - deferred.reject({reason:'handled', nav: '/client#/account/audio'}); + else if (data.result == 'configure_gear') { + deferred.reject({reason: 'handled', nav: '/client#/account/audio'}); } - else if(data.result == 'session') { + else if (data.result == 'session') { deferred.resolve(); } else { @@ -409,43 +445,49 @@ } // tests both device config, and network score - gearUtils.guardAgainstInvalidConfiguration = function(app) { + gearUtils.guardAgainstInvalidConfiguration = function (app, verifyNetworkScore) { var deferred = new $.Deferred(); gearUtils.guardAgainstInvalidGearConfiguration(app) - .fail(function() { + .fail(function () { deferred.reject(); }) - .done(function() { - gearUtils.guardAgainstBadNetworkScore(app) - .fail(function() { - deferred.reject(); - }) - .done(function() { - deferred.resolve(); - }) + .done(function () { + if(verifyNetworkScore) { + gearUtils.guardAgainstBadNetworkScore(app) + .fail(function () { + deferred.reject(); + }) + .done(function () { + deferred.resolve(); + }) + } + else { + deferred.resolve(); + } + }) return deferred; } - gearUtils.skipNetworkTest = function() { + gearUtils.skipNetworkTest = function () { context.jamClient.SetNetworkTestScore(gearUtils.SKIPPED_NETWORK_TEST); gearUtils.skippedNetworkTest = true; } - gearUtils.isNetworkTestSkipped = function() { + gearUtils.isNetworkTestSkipped = function () { return gearUtils.skippedNetworkTest; } - gearUtils.validNetworkScore = function() { + gearUtils.validNetworkScore = function () { return gearUtils.skippedNetworkTest || context.jamClient.GetNetworkTestScore() >= 2; } - gearUtils.isRestartingAudio = function() { + gearUtils.isRestartingAudio = function () { return !!reloadAudioTimeout; } - gearUtils.scheduleAudioRestart = function(location, initial_delay, beforeScan, afterScan, cancelScan) { + gearUtils.scheduleAudioRestart = function (location, initial_delay, beforeScan, afterScan, cancelScan) { logger.debug("scheduleAudioRestart: (from " + location + ")") @@ -453,40 +495,42 @@ function clearAudioReloadTimer() { - if(!cancellable) {return;} + if (!cancellable) { + return; + } - if(cancelScan) { + if (cancelScan) { cancelScan(); } - else if(afterScan) { + else if (afterScan) { afterScan(true); } clearTimeout(reloadAudioTimeout); reloadAudioTimeout = null; - currentAudioRestartLocation = null; + currentAudioRestartLocation = null; cancellable = false; } // refresh timer if outstanding - if(reloadAudioTimeout) { + if (reloadAudioTimeout) { logger.debug("scheduleAudioRestart: clearing timeout (from " + location + ")") clearTimeout(reloadAudioTimeout); } currentAudioRestartLocation = location; - if(beforeScan) { + if (beforeScan) { beforeScan(); } - reloadAudioTimeout = setTimeout(function() { + reloadAudioTimeout = setTimeout(function () { logger.debug("scheduleAudioRestart: rescan beginning (from " + location + ")") reloadAudioTimeout = null; currentAudioRestartLocation = null; cancellable = false; - if(afterScan) { + if (afterScan) { afterScan(false); } }, initial_delay ? initial_delay : 5000); @@ -494,4 +538,45 @@ return clearAudioReloadTimer; } + gearUtils.bootstrapDefaultPlaybackProfile = function () { + + var profiles = gearUtils.getProfiles(); + + var foundSystemDefaultPlaybackOnly = false + context._.each(profiles, function (profile) { + if (profile.id == SYSTEM_DEFAULT_PLAYBACK_ONLY) { + foundSystemDefaultPlaybackOnly = true + return false; + } + }) + + if (!foundSystemDefaultPlaybackOnly) { + logger.debug("creating system default profile (playback only") + if(!gearUtils.createDefaultPlaybackOnlyProfile()) { + logger.error("unable to create the default playback profile!"); + } + } + } + gearUtils.createDefaultPlaybackOnlyProfile = function () { + + var eMixerInputSampleRate = { + JAMKAZAM_AUTO_SR: 0, + USE_DEVICE_DEFAULT_SR: 1, + PREFER_44: 2, + PREFER_48: 3, + PREFER_96: 4 + } + + // null//upgrade protect + if(context.jamClient.FTUECreateUpdatePlayBackProfile) { + return context.jamClient.FTUECreateUpdatePlayBackProfile(SYSTEM_DEFAULT_PLAYBACK_ONLY, + eMixerInputSampleRate.JAMKAZAM_AUTO_SR, + 0, // buffering + false); // start audio + } + else { + return false; + } + } + })(window, jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/wizard/wizard.js b/web/app/assets/javascripts/wizard/wizard.js index 6d8600e3e..d0b1d45fc 100644 --- a/web/app/assets/javascripts/wizard/wizard.js +++ b/web/app/assets/javascripts/wizard/wizard.js @@ -69,11 +69,15 @@ if(result === false) {return false;} } + moveToNext(); + return false; + } + + function moveToNext() { previousStep = step; step = step + 1; moveToStep(); - return false; } function help() { @@ -238,6 +242,7 @@ this.getNextButton = getNextButton; this.setNextState = setNextState; this.setBackState = setBackState; + this.moveToNext = moveToNext; this.getCurrentStep = getCurrentStep; this.getCurrentWizardStep = getCurrentWizardStep; this.onCloseDialog = onCloseDialog; diff --git a/web/app/assets/stylesheets/client/help.css.scss b/web/app/assets/stylesheets/client/help.css.scss index 512988936..6dbfa7149 100644 --- a/web/app/assets/stylesheets/client/help.css.scss +++ b/web/app/assets/stylesheets/client/help.css.scss @@ -45,6 +45,10 @@ body.jam, body.web, .dialog{ } } + .help-high-latency-notice { + width:400px; + } + .help-hover-recorded-tracks, .help-hover-stream-mix, .help-hover-recorded-backing-tracks { font-size:12px; diff --git a/web/app/assets/stylesheets/client/session.css.scss b/web/app/assets/stylesheets/client/session.css.scss index 00fde3ac1..700758035 100644 --- a/web/app/assets/stylesheets/client/session.css.scss +++ b/web/app/assets/stylesheets/client/session.css.scss @@ -210,6 +210,18 @@ $otheraudio-minwidth:195px; $otheraudio-open-minwidth:230px; + #session-mytracks-notracks { + display:none; + + p { + font-size:14px; + white-space:normal; + margin:10px 10px 0 0; + line-height:125%; + } + + } + .session-mytracks { padding-left:15px; float:left; @@ -251,6 +263,12 @@ .recording-controls { min-width:230px; } + + #recording-start-stop { + @include border-radius(4px); + padding-left:5px; + padding-right:5px; + } } .session-recordings { @@ -358,6 +376,25 @@ #tracks { margin-top:12px; overflow:auto; + + &.no-local-tracks { + + #session-mytracks-notracks { + display:block; + } + + #session-mytracks-container { + display:none; + } + + #recording-start-stop { + display:none; + } + + #session-invite-musicians { + display:none; + } + } } .track-empty a { diff --git a/web/app/assets/stylesheets/client/wizard/gearWizard.css.scss b/web/app/assets/stylesheets/client/wizard/gearWizard.css.scss index 6916f7c60..5692b085b 100644 --- a/web/app/assets/stylesheets/client/wizard/gearWizard.css.scss +++ b/web/app/assets/stylesheets/client/wizard/gearWizard.css.scss @@ -429,8 +429,12 @@ } + .instructions { + height: 228px !important; + } + .network-test-results { - height: 248px !important; + height: 228px !important; @include border_box_sizing; &.testing { diff --git a/web/app/assets/stylesheets/dialogs/gettingStartDialog.css.scss b/web/app/assets/stylesheets/dialogs/gettingStartDialog.css.scss index a3ada5d95..401eea8a9 100644 --- a/web/app/assets/stylesheets/dialogs/gettingStartDialog.css.scss +++ b/web/app/assets/stylesheets/dialogs/gettingStartDialog.css.scss @@ -135,6 +135,25 @@ } + .get-a-free-jamtrack-section { + + &.has-free-jamtrack { + h2.get-a-free-jamtrack { + display:block; + } + + .action-button { + margin-top:-7px; + } + } + + &.no-free-jamtrack { + h2.browse-jamtracks { + display:block; + } + } + } + .ftue-inner table a { text-decoration:none; } diff --git a/web/app/assets/stylesheets/dialogs/singlePlayerProfileGuard.css.scss b/web/app/assets/stylesheets/dialogs/singlePlayerProfileGuard.css.scss new file mode 100644 index 000000000..89eedac9a --- /dev/null +++ b/web/app/assets/stylesheets/dialogs/singlePlayerProfileGuard.css.scss @@ -0,0 +1,29 @@ +#single-player-profile-dialog { + + .dialog-inner { + + &.high-latency { + .high-latency { + display:block + } + } + + &.has-no-inputs { + .has-no-inputs { + display:block + } + } + } + + .audio-latency { + font-weight:bold; + } + + .action-buttons { + margin:20px 0; + } + + p { + line-height:125%; + } +} \ No newline at end of file diff --git a/web/app/controllers/api_users_controller.rb b/web/app/controllers/api_users_controller.rb index aff39557d..56c9f7b38 100644 --- a/web/app/controllers/api_users_controller.rb +++ b/web/app/controllers/api_users_controller.rb @@ -46,6 +46,7 @@ class ApiUsersController < ApiController @user.update_instruments(params[:instruments].nil? ? [] : params[:instruments]) if params.has_key?(:instruments) @user.update_genres(params[:genres].nil? ? [] : params[:genres]) if params.has_key?(:genres) @user.show_whats_next = params[:show_whats_next] if params.has_key?(:show_whats_next) + @user.show_whats_next_count = params[:show_whats_next_count] if params.has_key?(:show_whats_next_count) @user.subscribe_email = params[:subscribe_email] if params.has_key?(:subscribe_email) @user.biography = params[:biography] if params.has_key?(:biography) @user.mod_merge(params[:mods]) if params[:mods] diff --git a/web/app/views/api_users/show.rabl b/web/app/views/api_users/show.rabl index 39d8624ac..2e50c7dcb 100644 --- a/web/app/views/api_users/show.rabl +++ b/web/app/views/api_users/show.rabl @@ -10,7 +10,7 @@ end # give back more info if the user being fetched is yourself if @user == current_user - attributes :email, :original_fpfile, :cropped_fpfile, :crop_selection, :session_settings, :show_whats_next, :subscribe_email, :auth_twitter, :new_notifications + attributes :email, :original_fpfile, :cropped_fpfile, :crop_selection, :session_settings, :show_whats_next, :show_whats_next_count, :subscribe_email, :auth_twitter, :new_notifications node :geoiplocation do |user| geoiplocation = current_user.geoiplocation diff --git a/web/app/views/clients/_help.html.slim b/web/app/views/clients/_help.html.slim index 38fa7c4dd..235c23381 100644 --- a/web/app/views/clients/_help.html.slim +++ b/web/app/views/clients/_help.html.slim @@ -30,6 +30,29 @@ script type="text/template" id="template-help-can-move-on" script type="text/template" id="template-help-tweak-asio-settings" | Click here to try faster ASIO settings. +script type="text/template" id="template-help-high-latency-notice" + .help-high-latency-notice + | {% if(data.additional == 'asio') { %} + p.gear-specific-latency-notice Tip: click the ASIO SETTINGS button to try faster ASIO settings. + p + | If you are unable to get your audio gear latency below 20 milliseconds, you can click NEXT to proceed through setup with a high-latency audio profile. This will allow you to play with JamTracks and backing tracks, but not play with others.  + p + a href="https://jamkazam.desk.com/customer/portal/articles/1520627-my-audio-gear-won-t-pass-latency-or-i-o-tests" rel="external" Click here + |  for more troubleshooting tips to speed up your audio gear setup. + | {% } else if(data.additional == 'macosx-builtin') { %} + p.gear-specific-latency-notice Tip: Insert your headphones on a Mac to bring your latency down, and click the RESYNC button to try again. + p + | If you are unable to get your audio gear latency below 20 milliseconds, you can click NEXT to proceed through setup with a high-latency audio profile. This will allow you to play with JamTracks and backing tracks, but not play with others.  + p + a href="https://jamkazam.desk.com/customer/portal/articles/1520627-my-audio-gear-won-t-pass-latency-or-i-o-tests" rel="external" Click here + |  for more troubleshooting tips to speed up your audio gear setup. + | {% } else { %} + p.general-info + | Your computer and interface are processing audio too slowly to play online in real-time sessions with other musicians over the Internet. You may click NEXT to proceed through setup to play alone in sessions with JamTracks or backing tracks, or if you want to improve your speed score to play online,  + a href="https://jamkazam.desk.com/customer/portal/articles/1520627-my-audio-gear-won-t-pass-latency-or-i-o-tests" rel="external" click here + |  for a troubleshooting article. + | {% } %} + script type="text/template" id="template-help-session-plus-musicians" | Plus any interested JamKazam musicians that I approve. diff --git a/web/app/views/clients/_network_test.html.haml b/web/app/views/clients/_network_test.html.haml index 0a2e34d64..db61e567a 100644 --- a/web/app/views/clients/_network_test.html.haml +++ b/web/app/views/clients/_network_test.html.haml @@ -1,5 +1,5 @@ .network-test - .help-text In this step, you will test your router and Internet connection to ensure that you can play in online sessions, and to see how many musicians can be in a session with you based on your internet connection. + .help-text In this step, you will test your router and Internet connection to ensure that you can play in online sessions, and to see how many musicians can be in a session with you based on your internet connection. If you don't want to play online in real-time sessions, you can click NEXT to skip this step. .wizard-step-content .wizard-step-column %h2 Instructions diff --git a/web/app/views/clients/_session.html.slim b/web/app/views/clients/_session.html.slim index ae422b6b7..3ffaa3b68 100644 --- a/web/app/views/clients/_session.html.slim +++ b/web/app/views/clients/_session.html.slim @@ -40,6 +40,12 @@ span | Settings .session-tracks-scroller + #session-mytracks-notracks + p.notice + | You have not set up any inputs for your instrument or vocals.  + | If you want to hear yourself play through the JamKazam app,  + | and let the app mix your live playing with JamTracks, or with other musicians in online sessions,  + a.open-ftue-no-tracks href='#' click here now. #session-mytracks-container #voice-chat.voicechat[style="display:none;" mixer-id=""] .voicechat-label diff --git a/web/app/views/clients/index.html.erb b/web/app/views/clients/index.html.erb index 3b28d1209..958c6aa99 100644 --- a/web/app/views/clients/index.html.erb +++ b/web/app/views/clients/index.html.erb @@ -249,8 +249,6 @@ var jamtrackScreen = new JK.JamTrackScreen(JK.app); jamtrackScreen.initialize(); - - var jamtrackLanding = new JK.JamTrackLanding(JK.app); jamtrackLanding.initialize(); @@ -299,6 +297,9 @@ var allSyncsDialog = new JK.AllSyncsDialog(JK.app); allSyncsDialog.initialize(); + var singlePlayerProfileGuardDialog = new JK.SinglePlayerProfileGuardDialog(JK.app); + singlePlayerProfileGuardDialog.initialize(); + // do a client update early check upon initialization JK.ClientUpdateInstance.check() @@ -312,6 +313,9 @@ var jamServer = new JK.JamServer(JK.app, function(event_type) {JK.app.activeElementEvent(event_type)}); jamServer.initialize(); + var clientInit = new JK.ClientInit(); + clientInit.init(); + // latency_tester does not want to be here - redirect it if(window.jamClient.getOperatingMode && window.jamClient.getOperatingMode() == "server") { window.location = "/latency_tester"; diff --git a/web/app/views/dialogs/_dialogs.html.haml b/web/app/views/dialogs/_dialogs.html.haml index 48447424f..8c1a2d817 100644 --- a/web/app/views/dialogs/_dialogs.html.haml +++ b/web/app/views/dialogs/_dialogs.html.haml @@ -37,3 +37,4 @@ = render 'dialogs/openBackingTrackDialog' = render 'dialogs/loginRequiredDialog' = render 'dialogs/jamtrackPaymentHistoryDialog' += render 'dialogs/singlePlayerProfileGuard' \ No newline at end of file diff --git a/web/app/views/dialogs/_gettingStartedDialog.html.slim b/web/app/views/dialogs/_gettingStartedDialog.html.slim index 88e674e8c..df934ab25 100644 --- a/web/app/views/dialogs/_gettingStartedDialog.html.slim +++ b/web/app/views/dialogs/_gettingStartedDialog.html.slim @@ -36,16 +36,24 @@ a href="#" class="google-invite" = image_tag "content/icon_google.png", {:align=>"absmiddle", :height => 26, :width => 26 } span Google+ + .column.get-a-free-jamtrack-section + h2.get-a-free-jamtrack.hidden GET A FREE JAMTRACK + h2.browse-jamtracks.hidden CHECK OUT JAMTRACKS + .blurb + | JamTracks are the best way to play with your favorite music. Unlike traditional backing tracks,  + | they are complete multitrack recordings, with fully isolated tracks for each part.  + span.jamtracks-limited-time.hidden For a limited time, you can get your first JamTrack free. + | Check it out! + .action-button + a.button-orange.browse-jamtrack rel="external" href="#" LEARN MORE + br clear="both" + .row.find-connect .column h2 CREATE A "REAL" SESSION .blurb - | You can create a session to start immediately and hope others join, but this doesn’t work well. It’s better - to schedule a session and invite friends or the community to join you. Watch a video to learn how, then - schedule your first session! + | You can create sessions that start immediately and see who joins, or you can schedule sessions, invite friends, and others from the community, and manage RSVPs. Learn how. .action-button a.button-orange rel="external" href="https://www.youtube.com/watch?v=EZZuGcDUoWk" WATCH VIDEO - br clear="both" - .row.find-connect .column h2 FIND SESSIONS TO JOIN .blurb @@ -53,14 +61,6 @@ to learn about how to find and select good sessions to join. .action-button a.button-orange.setup-gear rel="external" href="https://www.youtube.com/watch?v=xWponSJo-GU" WATCH VIDEO - - .column - h2 CONNECT WITH MUSICIANS - .blurb - | To play more music, tap into our growing - community to connect with other musicians. Watch this video for tips on how to do this. - .action-button - a.button-orange rel="external" href="https://www.youtube.com/watch?v=4KWklSZZxRc" WATCH VIDEO br clear="both" .row.full.learn-more .column diff --git a/web/app/views/dialogs/_singlePlayerProfileGuard.html.slim b/web/app/views/dialogs/_singlePlayerProfileGuard.html.slim new file mode 100644 index 000000000..d7c1628d3 --- /dev/null +++ b/web/app/views/dialogs/_singlePlayerProfileGuard.html.slim @@ -0,0 +1,22 @@ +.dialog.dialog-overlay-sm layout='dialog' layout-id='single-player-profile-dialog' id='single-player-profile-dialog' + .content-head + = image_tag "content/icon_alert.png", {:width => 24, :height => 24, :class => 'content-icon' } + h1 Application Notice + .dialog-inner + + p.high-latency.hidden + | Your audio profile has a latency score of  + span.audio-latency + br + br + | This is too high to play with others in real-time. However, you can play with JamTracks and backing tracks by yourself in a private session, or go to the gear setup wizard and add a new audio profile with lower latency. + + p.has-no-inputs.hidden + | You are currently using the default system profile, which has no audio inputs. + br + br + | With this profile, you can't play with others in real-time. However, you can play with JamTracks and backing tracks by yourself in a private session, or go to the gear setup wizard and add a new audio profile that uses your gear. + .right.action-buttons + a.button-grey.btn-cancel href='#' layout-action="cancel" CANCEL + a.button-grey.btn-gear-setup href="/client#/account/audio" GO TO GEAR SETUP + a.button-orange.btn-private-session href="#" PRIVATE SESSION \ No newline at end of file diff --git a/web/spec/features/getting_started_dialog_spec.rb b/web/spec/features/getting_started_dialog_spec.rb index 1c973f6cb..d7186cfe3 100644 --- a/web/spec/features/getting_started_dialog_spec.rb +++ b/web/spec/features/getting_started_dialog_spec.rb @@ -10,8 +10,7 @@ describe "Home Screen", :js => true, :type => :feature, :capybara_feature => tru Capybara.default_wait_time = 10 end - let(:user) { FactoryGirl.create(:user, :show_whats_next => true) } - + let(:user) { FactoryGirl.create(:user, show_whats_next: true, has_redeemable_jamtrack: true) } describe "in normal browser" do before(:each) do @@ -24,6 +23,36 @@ describe "Home Screen", :js => true, :type => :feature, :capybara_feature => tru find('#getting-started-dialog .setup-gear-btn').trigger('click') should have_selector('p', text: 'To configure your audio gear, you must use the JamKazam application.') end + + it "should show jam track browsing page" do + find('#getting-started-dialog span.jamtracks-limited-time') + find('#getting-started-dialog h2.get-a-free-jamtrack') + expect(page).to have_selector('#getting-started-dialog .browse-jamtracks', visible: false) + + find('#getting-started-dialog a.browse-jamtrack').trigger('click') + should have_selector('h1', text: 'jamtracks') + end + + end + + describe "in normal browser with redeemed jamtrack" do + let(:redeemed_user) { FactoryGirl.create(:user, show_whats_next: true, has_redeemable_jamtrack: false) } + + before(:each) do + sign_in_poltergeist redeemed_user + visit "/client" + should have_selector('h1', text: 'getting started') + end + + it "should show jam track browsing page" do + find('#getting-started-dialog h2.browse-jamtracks') + expect(page).to have_selector('#getting-started-dialog h2.get-a-free-jamtrack', visible: false) + expect(page).to have_selector('#getting-started-dialog span.jamtracks-limited-time', visible: false) + + find('#getting-started-dialog .browse-jamtrack').trigger('click') + should have_selector('h1', text: 'jamtracks') + end + end describe "in native client" do diff --git a/web/spec/requests/active_music_sessions_api_spec.rb b/web/spec/requests/active_music_sessions_api_spec.rb index de4fae3ed..61836348e 100755 --- a/web/spec/requests/active_music_sessions_api_spec.rb +++ b/web/spec/requests/active_music_sessions_api_spec.rb @@ -310,7 +310,7 @@ describe "Active Music Session API ", :type => :api do JSON.parse(last_response.body)["errors"]["genre"].should == ["can't be blank"] end - it "should error with no track specified" do + it "should not error with no track specified" do original_count = ActiveMusicSession.all().length client = FactoryGirl.create(:connection, :user => user, :ip_address => "1.1.1.1") @@ -320,10 +320,7 @@ describe "Active Music Session API ", :type => :api do music_session = JSON.parse(last_response.body) post "/api/sessions/#{music_session["id"]}/participants.json", { :client_id => client.client_id, :as_musician => true, :tracks => []}.to_json, "CONTENT_TYPE" => 'application/json' - JSON.parse(last_response.body)["errors"]["tracks"].should == [ValidationMessages::SELECT_AT_LEAST_ONE] - - # check that the transaction was rolled back - ActiveMusicSession.all().length.should == original_count + last_response.status.should eq(201) end it "should error with invalid track specified" do From fd02e23572ef0f011a2ac9ba0c015e97f981a0f3 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Wed, 8 Apr 2015 22:43:57 -0500 Subject: [PATCH 02/12] * updated home page VRFS-2868 --- ruby/lib/jam_ruby/models/jam_track.rb | 2 +- .../assets/javascripts/dialog/videoDialog.js | 3 +- .../javascripts/jam_track_screen.js.coffee | 1 - web/app/assets/javascripts/web/home.js | 18 ++ web/app/assets/javascripts/web/web.js | 2 +- web/app/assets/stylesheets/web/home.css.scss | 135 ++++++++++ web/app/assets/stylesheets/web/web.css | 1 + .../assets/stylesheets/web/welcome.css.scss | 2 +- web/app/controllers/landings_controller.rb | 2 +- web/app/controllers/users_controller.rb | 19 +- web/app/views/users/home.html.slim | 63 +++++ web/config/routes.rb | 2 +- web/spec/features/home_page_spec.rb | 158 ++++++++++++ web/spec/features/signin_spec.rb | 4 +- web/spec/features/text_message_spec.rb | 4 +- web/spec/features/twitter_auth_spec.rb | 10 +- web/spec/features/welcome_spec.rb | 235 ------------------ web/spec/support/utilities.rb | 31 ++- 18 files changed, 438 insertions(+), 254 deletions(-) create mode 100644 web/app/assets/javascripts/web/home.js create mode 100644 web/app/assets/stylesheets/web/home.css.scss create mode 100644 web/app/views/users/home.html.slim create mode 100644 web/spec/features/home_page_spec.rb delete mode 100644 web/spec/features/welcome_spec.rb diff --git a/ruby/lib/jam_ruby/models/jam_track.rb b/ruby/lib/jam_ruby/models/jam_track.rb index 31bf42893..5c8eb1d3a 100644 --- a/ruby/lib/jam_ruby/models/jam_track.rb +++ b/ruby/lib/jam_ruby/models/jam_track.rb @@ -17,7 +17,7 @@ module JamRuby :original_artist, :songwriter, :publisher, :licensor, :licensor_id, :pro, :genre, :genre_id, :sales_region, :price, :reproduction_royalty, :public_performance_royalty, :reproduction_royalty_amount, :licensor_royalty_amount, :pro_royalty_amount, :plan_code, :initial_play_silence, :jam_track_tracks_attributes, - :jam_track_tap_ins_attributes, :version, :jmep_json, :jmep_text, :pro_ascap, :pro_bmi, :pro_sesac, as: :admin + :jam_track_tap_ins_attributes, :version, :jmep_json, :jmep_text, :pro_ascap, :pro_bmi, :pro_sesac, :duration, as: :admin validates :name, presence: true, uniqueness: true, length: {maximum: 200} validates :plan_code, presence: true, uniqueness: true, length: {maximum: 50 } diff --git a/web/app/assets/javascripts/dialog/videoDialog.js b/web/app/assets/javascripts/dialog/videoDialog.js index 3d5a41707..01ab3dc6b 100644 --- a/web/app/assets/javascripts/dialog/videoDialog.js +++ b/web/app/assets/javascripts/dialog/videoDialog.js @@ -14,7 +14,7 @@ if (!context.jamClient || !context.jamClient.IsNativeClient()) { $('#video-dialog-header').html($self.data('video-header') || $self.attr('data-video-header')); - $('#video-dialog-iframe').attr('src', $self.data('video-url') || $self.atr('data-video-url')); + $('#video-dialog-iframe').attr('src', $self.data('video-url') || $self.attr('data-video-url')); app.layout.showDialog('video-dialog'); e.stopPropagation(); e.preventDefault(); @@ -29,6 +29,7 @@ function events() { $('.carousel .slides').on('click', '.slideItem', videoClick); $('.video-slide').on('click', videoClick); + $('.video-item').on('click', videoClick); $(dialogId + '-close').click(function (e) { app.layout.closeDialog('video-dialog'); diff --git a/web/app/assets/javascripts/jam_track_screen.js.coffee b/web/app/assets/javascripts/jam_track_screen.js.coffee index 9aaedfe3a..6cea8e994 100644 --- a/web/app/assets/javascripts/jam_track_screen.js.coffee +++ b/web/app/assets/javascripts/jam_track_screen.js.coffee @@ -61,7 +61,6 @@ context.JK.JamTrackScreen=class JamTrackScreen for v in raw_vars [key, val] = v.split("=") params[key] = decodeURIComponent(val) - ms params refresh:() => diff --git a/web/app/assets/javascripts/web/home.js b/web/app/assets/javascripts/web/home.js new file mode 100644 index 000000000..5eb7c1ed1 --- /dev/null +++ b/web/app/assets/javascripts/web/home.js @@ -0,0 +1,18 @@ +(function (context, $) { + + "use strict"; + + context.JK = context.JK || {}; + + var rest = context.JK.Rest(); + var logger = context.JK.logger; + + function initialize() { + if(gon.signed_in) { + window.location = "/client#/home" + } + } + context.JK.HomePage = initialize; + + +})(window, jQuery); \ No newline at end of file diff --git a/web/app/assets/javascripts/web/web.js b/web/app/assets/javascripts/web/web.js index a36ad12e2..7f128d092 100644 --- a/web/app/assets/javascripts/web/web.js +++ b/web/app/assets/javascripts/web/web.js @@ -61,7 +61,7 @@ //= require web/sessions //= require web/session_info //= require web/recordings -//= require web/welcome +//= require web/home //= require web/individual_jamtrack //= require web/individual_jamtrack_band //= require fakeJamClient diff --git a/web/app/assets/stylesheets/web/home.css.scss b/web/app/assets/stylesheets/web/home.css.scss new file mode 100644 index 000000000..4a82efe37 --- /dev/null +++ b/web/app/assets/stylesheets/web/home.css.scss @@ -0,0 +1,135 @@ +@charset "UTF-8"; + +@import "client/common.css.scss"; + +body.web.home { + + .landing-tag { + margin-top:20px; + } + + .logo-home { + margin:20px 0 15px; + } + + .home-column { + margin-top: 20px; + width: 345px; + float: left; + margin-right: 30px; + text-align: center; + margin-bottom:35px; + + &.last{ + margin-right:0; + } + + h3 { + text-align:left; + margin-top:12px; + margin-bottom:6px; + font-size:18px; + font-weight:700; + } + p { + color:white; + text-align:left; + line-height:120%; + font-size:13px; + margin-bottom:20px; + } + + .extra-links { + width:234px; + display:inline-block; + } + .learn-more { + font-size: 12px; + margin-top: 5px; + + &.shared { + float:left; + margin-left:10px; + } + } + + .sign-in-holder { + font-size: 12px; + margin-top: 5px; + + &.shared { + float:right; + margin-right:10px; + } + } + + } + + .latest-promo { + float:left; + } + + .endorsement-promo { + float:right; + } + + .home-buzz { + + h2 { + width:100%; + text-align:center; + margin:20px 0; + } + width: 300px; + position:relative; + margin-right:20px; + .buzz-items { + .buzz-item { + padding: 12px 0; + &:last-child { + padding-bottom:0; + } + } + .buzz-item-text { + padding-left: 78px; // 58px width for image + 20px margin + } + } + } + + .latest { + width: 750px; + position:relative; + top:-45px; + + .home-session-list { + top:5px; // XXX remove post release + width:100%; + height:400px; + border: solid 1px #ed3618; + background-color:#353535; + float:left; + overflow:hidden; + position:relative; + + } + .latest-head { + position: absolute; + padding:20px 20px 12px; + height: 53px; + width:inherit; + } + + .latest-body { + width:100%; + top:65px; + bottom:0; + position:absolute; + overflow-y:scroll; + @include border_box_sizing; + + .session-list-wrapper { + padding: 0 20px; + } + } + } +} diff --git a/web/app/assets/stylesheets/web/web.css b/web/app/assets/stylesheets/web/web.css index ac4463b15..5761892c5 100644 --- a/web/app/assets/stylesheets/web/web.css +++ b/web/app/assets/stylesheets/web/web.css @@ -20,6 +20,7 @@ *= require web/footer *= require web/recordings *= require web/welcome +*= require web/home #= require web/sessions *= require web/events *= require web/session_info diff --git a/web/app/assets/stylesheets/web/welcome.css.scss b/web/app/assets/stylesheets/web/welcome.css.scss index 2171789eb..8417ca9a2 100644 --- a/web/app/assets/stylesheets/web/welcome.css.scss +++ b/web/app/assets/stylesheets/web/welcome.css.scss @@ -2,7 +2,7 @@ @import "client/common.css.scss"; -body.web { +body.web.welcome { .signin-common { height:auto; diff --git a/web/app/controllers/landings_controller.rb b/web/app/controllers/landings_controller.rb index e2df670cc..cd1038904 100644 --- a/web/app/controllers/landings_controller.rb +++ b/web/app/controllers/landings_controller.rb @@ -94,7 +94,7 @@ class LandingsController < ApplicationController jam_track = JamTrack.first end - gon.jam_track_plan_code = jam_track.plan_code + gon.jam_track_plan_code = jam_track.plan_code if jam_track render 'product_jamtracks', layout: 'web' end end diff --git a/web/app/controllers/users_controller.rb b/web/app/controllers/users_controller.rb index bcac17d3e..fbd431bc6 100644 --- a/web/app/controllers/users_controller.rb +++ b/web/app/controllers/users_controller.rb @@ -215,7 +215,24 @@ class UsersController < ApplicationController #@jamfest_2014 = Event.find_by_id('a2dfbd26-9b17-4446-8c61-b67a542ea6ee') unless @jamfest_2014 # development ID # temporary--end - @welcome_page = true + #@welcome_page = true + render :layout => "web" + end + + # DO NOT USE CURRENT_USER IN THIS ROUTINE. IT'S CACHED FOR THE WHOLE SITE + def home + + @no_user_dropdown = true + @promo_buzz = PromoBuzz.active + + if Rails.application.config.use_promos_on_homepage + @promo_latest = PromoLatest.active + else + @promo_latest, start = Feed.index(nil, limit: 10) + end + + + gon.signed_in = !current_user.nil? render :layout => "web" end diff --git a/web/app/views/users/home.html.slim b/web/app/views/users/home.html.slim new file mode 100644 index 000000000..57a635237 --- /dev/null +++ b/web/app/views/users/home.html.slim @@ -0,0 +1,63 @@ +- provide(:page_name, 'home') + +.home-column + = link_to image_tag("web/thumbnail_jamtracks.jpg", :alt => "JamTracks explanatory video"), '#', class: "jamtracks-video video-item", 'data-video-header' => 'JamTracks', 'data-video-url' => 'http://www.youtube.com/embed/ylYcvTY9CVo?autoplay=1' + h3 Complete, Multi-Track Backing Tracks + + p + strong JamTracks + |  are the best way to play with your favorite music. Unlike traditional backing tracks, JamTracks are complete multitrack recordings, with fully isolated tracks for each part. + + = link_to image_tag("web/button_cta_jamtrack.png", width: 234, height:57), '/client#/jamtrack', class: 'cta-button jamtracks' + br clear="all" + .extra-links + .learn-more + a.learn-more-jamtracks href='/products/jamtracks' learn more + +.home-column + = link_to image_tag("web/thumbnail_platform.jpg", :alt => "JamKazam explanatory video!"), '#', class: "platform-video video-item", 'data-video-header' => 'JamKazam Platform', 'data-video-url' => 'http://www.youtube.com/embed/ylYcvTY9CVo?autoplay=1' + h3 Online Music Collaboration Platform + + p + strong JamKazam + |  is an innovative live music platform and social network, enabling musicians to play music together in real time from different locations over the Internet as if they are sitting in the same room. + + = link_to image_tag("web/button_cta_platform.png", width: 234, height: 57), '/signup', class: 'cta-button platform' + .extra-links + span.learn-more.shared + a.learn-more-platform href='/products/platform' learn more + span.sign-in-holder.shared + a.sign-in href='/signin' sign in + + br clear="all" + +.home-column.last + = link_to image_tag("web/thumbnail_jamblaster.jpg", :alt => "JamBlaster explanatory video!"), '#', class: "jamblaster-video video-item", 'data-video-header' => 'JamBlaster', 'data-video-url' => 'http://www.youtube.com/embed/gAJAIHMyois?autoplay=1' + h3 Ultra Low-Latency Audio Interface + + p + | The  + strong JamBlaster + |  is a device designed from the ground up to meet the requirements of online music play, vastly extending the range over which musicians can play together across the Internet. + + = link_to image_tag("web/button_cta_jamblaster.png", width: 234, height: 57), '/products/jamblaster', class: 'cta-button jamblaster' + .extra-links + .learn-more + a.learn-more-jamblaster href='/products/jamblaster' learn more + br clear="all" + +br clear="all" + +- content_for :after_black_bar do + .latest-promo + = render :partial => "latest" + .endorsement-promo + .home-buzz + h2 What Musicians in the JamKazam Community Are Saying + = link_to image_tag("web/thumbnail_buzz.jpg", :alt => "JamKazam Endorsements!", width:300), '#', class: "endorsements-video video-item", 'data-video-header' => 'JamKazam Community', 'data-video-url' => 'http://www.youtube.com/embed/_7qj5RXyHCo?autoplay=1' + + + br clear="all" + +javascript: + window.JK.HomePage(); diff --git a/web/config/routes.rb b/web/config/routes.rb index 021dc0efc..7cdbce911 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -8,7 +8,7 @@ SampleApp::Application.routes.draw do resources :users resources :sessions, only: [:new, :create, :destroy] - root to: 'users#welcome' + root to: 'users#home' # signup, and signup completed, related pages match '/signup', to: 'users#new', :via => 'get' diff --git a/web/spec/features/home_page_spec.rb b/web/spec/features/home_page_spec.rb new file mode 100644 index 000000000..96ed58527 --- /dev/null +++ b/web/spec/features/home_page_spec.rb @@ -0,0 +1,158 @@ +require 'spec_helper' + +describe "Home Page", :js => true, :type => :feature, :capybara_feature => true do + + subject { page } + + before(:all) do + Capybara.javascript_driver = :poltergeist + Capybara.current_driver = Capybara.javascript_driver + Capybara.default_wait_time = 10 + end + + before(:each) do + Feed.delete_all + MusicSessionUserHistory.delete_all + MusicSession.delete_all + Recording.delete_all + + emulate_client + visit "/" + find('h1', text: 'Live music platform & social network for musicians') + + end + + let(:user) { FactoryGirl.create(:user) } + let(:fb_auth) { + { :provider => "facebook", + :uid => "1234", + :info => {:name => "John Doe", + :email => "johndoe@email.com"}, + :credentials => {:token => "testtoken234tsdf", :expires_at => 2391456019}, + :extra => { :raw_info => {:first_name => 'John', :last_name => 'Doe', :email => 'facebook@jamkazam.com', :gender => 'male'}} } + } + + it "links work" do + + # learn more about JamTracks + find('.learn-more-jamtracks').trigger(:click) + find('h1.product-headline', text: 'JamTracks by JamKazam') + + visit '/' + + # learn more about the platform + find('.learn-more-platform').trigger(:click) + find('h1.product-headline', text: 'The JamKazam Platform') + + visit '/' + + # learn more about the platform + find('.learn-more-jamblaster').trigger(:click) + find('h1.product-headline', text: 'The JamBlaster by JamKazam') + + visit '/' + + # try to sign in + find('a.sign-in').trigger(:click) + find('h1', text: 'sign in or register') + + visit '/' + + # try to click jamtrack CTA button + find('a.cta-button.jamtracks').trigger(:click) + find('h1', text: 'jamtracks') + + visit '/' + + # try to click platform CTA button + find('a.cta-button.platform').trigger(:click) + find('h2', text: '1Create your free JamKazam account') + + visit '/' + + # try to click jamblaster CTA button + find('a.cta-button.jamblaster').trigger(:click) + find('h1.product-headline', text: 'The JamBlaster by JamKazam') + + end + + it "signed in user gets redirected to app home page" do + fast_signin(user,'/') + find('h2', text: 'create session') + end + + + describe "feed" do + + it "data" do + claimedRecording1 = FactoryGirl.create(:claimed_recording) + MusicSession1 = claimedRecording1.recording.music_session.music_session + + visit "/" + find('h1', text: 'Live music platform & social network for musicians') + find('.feed-entry.music-session-history-entry .description', text: MusicSession1.description) + find('.feed-entry.music-session-history-entry .session-status', text: 'BROADCASTING OFFLINE') + find('.feed-entry.music-session-history-entry .session-controls.inprogress', text: 'BROADCASTING OFFLINE') + find('.feed-entry.music-session-history-entry .artist', text: MusicSession1.creator.name) + should_not have_selector('.feed-entry.music-session-history-entry .musician-detail') + + find('.feed-entry.recording-entry .name', text: claimedRecording1.name) + find('.feed-entry.recording-entry .description', text: claimedRecording1.description) + find('.feed-entry.recording-entry .title', text: 'RECORDING') + find('.feed-entry.recording-entry .artist', text: claimedRecording1.user.name) + should_not have_selector('.feed-entry.recording-entry .musician-detail') + + # try to hide the recording + claimedRecording1.is_public = false + claimedRecording1.save! + + visit "/" + find('h1', text: 'Live music platform & social network for musicians') + find('.feed-entry.music-session-history-entry .description', text: MusicSession1.description) + should_not have_selector('.feed-entry.recording-entry') + + + # try to hide the music session + MusicSession1.fan_access = false + MusicSession1.save! + + visit "/" + find('h1', text: 'Live music platform & social network for musicians') + should have_selector('.feed-entry.music-session-history-entry') + + # try to mess with the music session history by removing all user histories (which makes it a bit invalid) + # but we really don't want the front page to ever crash if we can help it + MusicSession1.fan_access = true + MusicSession1.music_session_user_histories.delete_all + MusicSession1.reload + MusicSession1.music_session_user_histories.length.should == 0 + + visit "/" + find('h1', text: 'Live music platform & social network for musicians') + should_not have_selector('.feed-entry.music-session-history-entry') + end + end + + +=begin + describe "signin with facebook" do + + before(:each) do + user.user_authorizations.build provider: 'facebook', uid: '1234', token: 'abc', token_expiration: 1.days.from_now + user.save! + OmniAuth.config.mock_auth[:facebook] = OmniAuth::AuthHash.new(fb_auth) + end + + it "click will redirect to facebook for authorization" do + pending "move elsewhere" + + find('.signin-facebook').trigger(:click) + + wait_until_curtain_gone + + find('h2', text: 'musicians') + end + end +=end +end + diff --git a/web/spec/features/signin_spec.rb b/web/spec/features/signin_spec.rb index ad4f7dfec..699023a31 100644 --- a/web/spec/features/signin_spec.rb +++ b/web/spec/features/signin_spec.rb @@ -25,7 +25,7 @@ describe "signin" do click_button "SIGN IN" end - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root end # proves that redirect-to is preserved between failure @@ -47,7 +47,7 @@ describe "signin" do click_button "SIGN IN" end - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root end it "success with forum sso" do diff --git a/web/spec/features/text_message_spec.rb b/web/spec/features/text_message_spec.rb index 81bebc17e..45e4e77dc 100644 --- a/web/spec/features/text_message_spec.rb +++ b/web/spec/features/text_message_spec.rb @@ -80,7 +80,7 @@ describe "Text Message", :js => true, :type => :feature, :capybara_feature => tr it "can load directly into chat session from url" do visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root visit "/client#/home/text-message/d1=#{@user2.id}" find('h1', text: 'conversation with ' + @user2.name) end @@ -125,7 +125,7 @@ describe "Text Message", :js => true, :type => :feature, :capybara_feature => tr it "shows error with a notify" do visit '/' - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root visit "/client#/home/text-message/d1=#{@user2.id}" find('h1', text: 'conversation with ' + @user2.name) send_text_message('ass', should_fail:'profanity') diff --git a/web/spec/features/twitter_auth_spec.rb b/web/spec/features/twitter_auth_spec.rb index 40962ef94..ce28db090 100644 --- a/web/spec/features/twitter_auth_spec.rb +++ b/web/spec/features/twitter_auth_spec.rb @@ -28,12 +28,12 @@ describe "Welcome", :js => true, :type => :feature, :capybara_feature => true d emulate_client sign_in_poltergeist user visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root end it "redirects back when done, and updates user_auth" do visit '/auth/twitter' - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root sleep 1 user.reload auth = user.user_authorization('twitter') @@ -43,7 +43,7 @@ describe "Welcome", :js => true, :type => :feature, :capybara_feature => true d auth.secret.should == 'twitter_secret' visit '/auth/twitter' - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root user.reload auth = user.user_authorization('twitter') auth.uid.should == '1234' @@ -53,7 +53,7 @@ describe "Welcome", :js => true, :type => :feature, :capybara_feature => true d it "shows error when two users try to auth same twitter account" do visit '/auth/twitter' - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root sleep 1 user.reload auth = user.user_authorization('twitter') @@ -63,7 +63,7 @@ describe "Welcome", :js => true, :type => :feature, :capybara_feature => true d sign_in_poltergeist user2 visit '/' - find('h1', text: 'Play music together over the Internet as if in the same room') + should_be_at_root visit '/auth/twitter' find('li', text: 'This twitter account is already associated with someone else') end diff --git a/web/spec/features/welcome_spec.rb b/web/spec/features/welcome_spec.rb deleted file mode 100644 index 4a2cd9e8f..000000000 --- a/web/spec/features/welcome_spec.rb +++ /dev/null @@ -1,235 +0,0 @@ -require 'spec_helper' - -describe "Welcome", :js => true, :type => :feature, :capybara_feature => true do - - subject { page } - - before(:all) do - Capybara.javascript_driver = :poltergeist - Capybara.current_driver = Capybara.javascript_driver - Capybara.default_wait_time = 10 - end - - before(:each) do - Feed.delete_all - MusicSessionUserHistory.delete_all - MusicSession.delete_all - Recording.delete_all - - emulate_client - visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') - - end - - let(:user) { FactoryGirl.create(:user) } - let(:fb_auth) { - { :provider => "facebook", - :uid => "1234", - :info => {:name => "John Doe", - :email => "johndoe@email.com"}, - :credentials => {:token => "testtoken234tsdf", :expires_at => 2391456019}, - :extra => { :raw_info => {:first_name => 'John', :last_name => 'Doe', :email => 'facebook@jamkazam.com', :gender => 'male'}} } - } - - describe "signin" do - before(:each) do - find('#signin').trigger(:click) - end - - it "show dialog" do - should have_selector('h1', text: 'sign in') - end - - it "shows signup dialog if selected" do - find('.show-signup-dialog').trigger(:click) - - find('h1', text: 'sign up for jamkazam') - end - - it "forgot password" do - find('a.forgot-password').trigger(:click) - - find('h1', text: 'reset your password') - end - - it "closes if cancelled" do - find('a.signin-cancel').trigger(:click) - - should_not have_selector('h1', text: 'sign in') - end - - describe "signin natively" do - - it "redirects to client on login" do - within('.signin-form') do - fill_in "Email Address:", with: user.email - fill_in "Password:", with: user.password - click_button "SIGN IN" - end - - wait_until_curtain_gone - - find('h2', text: 'musicians') - end - - it "shows error if bad login" do - within('.signin-form') do - fill_in "Email Address:", with: "junk" - fill_in "Password:", with: user.password - click_button "SIGN IN" - end - - should have_selector('h1', text: 'sign in') - - find('div.login-error-msg', text: 'Invalid login') - end - end - - describe "redirect-to" do - - it "redirect on login" do - visit "/client#/account" - find('h1', text: 'sign in or register') - within('.signin-form') do - fill_in "Email Address:", with: user.email - fill_in "Password:", with: user.password - click_button "SIGN IN" - end - - wait_until_curtain_gone - - find('h2', text: 'identity:') - end - - it "redirect if already logged in" do - # this is a rare case - sign_in_poltergeist(user) - visit "/?redirect-to=" + ERB::Util.url_encode("/client#/account") - find('h1', text: 'Play music together over the Internet as if in the same room') - find('#signin').trigger(:click) - - wait_until_curtain_gone - - find('h2', text: 'identity:') - end - end - - describe "signin with facebook" do - - before(:each) do - user.user_authorizations.build provider: 'facebook', uid: '1234', token: 'abc', token_expiration: 1.days.from_now - user.save! - OmniAuth.config.mock_auth[:facebook] = OmniAuth::AuthHash.new(fb_auth) - end - - it "click will redirect to facebook for authorization" do - find('.signin-facebook').trigger(:click) - - wait_until_curtain_gone - - find('h2', text: 'musicians') - end - end - - end - - describe "signup" do - - before(:each) do - find('#signup').trigger(:click) - end - - it "show dialog" do - should have_selector('h1', text: 'sign up for jamkazam') - end - - it "shows signin dialog if selected" do - find('.show-signin-dialog').trigger(:click) - - find('h1', text: 'sign in') - end - - it "closes if cancelled" do - find('a.signup-cancel').trigger(:click) - - should_not have_selector('h1', text: 'sign in') - end - - describe "signup with email" do - - it "click will redirect to signup page" do - find('.signup-email').trigger(:click) - find('h2.create-account-header', text: '1Create your free JamKazam account') - end - end - - describe "signup with facebook" do - - before(:each) do - fb_auth[:uid] = '12345' - OmniAuth.config.mock_auth[:facebook] = OmniAuth::AuthHash.new(fb_auth) - end - - it "click will redirect to facebook for authorization" do - find('.signup-facebook').trigger(:click) - find('h2.create-account-header', text: '1Create your free JamKazam account') - find_field('jam_ruby_user[first_name]').value.should eq 'John' - find_field('jam_ruby_user[last_name]').value.should eq 'Doe' - find_field('jam_ruby_user[email]').value.should eq 'facebook@jamkazam.com' - end - end - end - - describe "feed" do - - it "data" do - claimedRecording1 = FactoryGirl.create(:claimed_recording) - MusicSession1 = claimedRecording1.recording.music_session.music_session - - visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') - find('.feed-entry.music-session-history-entry .description', text: MusicSession1.description) - find('.feed-entry.music-session-history-entry .session-status', text: 'BROADCASTING OFFLINE') - find('.feed-entry.music-session-history-entry .session-controls.inprogress', text: 'BROADCASTING OFFLINE') - find('.feed-entry.music-session-history-entry .artist', text: MusicSession1.creator.name) - should_not have_selector('.feed-entry.music-session-history-entry .musician-detail') - - find('.feed-entry.recording-entry .name', text: claimedRecording1.name) - find('.feed-entry.recording-entry .description', text: claimedRecording1.description) - find('.feed-entry.recording-entry .title', text: 'RECORDING') - find('.feed-entry.recording-entry .artist', text: claimedRecording1.user.name) - should_not have_selector('.feed-entry.recording-entry .musician-detail') - - # try to hide the recording - claimedRecording1.is_public = false - claimedRecording1.save! - - visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') - find('.feed-entry.music-session-history-entry .description', text: MusicSession1.description) - should_not have_selector('.feed-entry.recording-entry') - - - # try to hide the music session - MusicSession1.fan_access = false - MusicSession1.save! - - visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') - should have_selector('.feed-entry.music-session-history-entry') - - # try to mess with the music session history by removing all user histories (which makes it a bit invalid) - # but we really don't want the front page to ever crash if we can help it - MusicSession1.fan_access = true - MusicSession1.music_session_user_histories.delete_all - MusicSession1.reload - MusicSession1.music_session_user_histories.length.should == 0 - - visit "/" - find('h1', text: 'Play music together over the Internet as if in the same room') - should_not have_selector('.feed-entry.music-session-history-entry') - end - end -end - diff --git a/web/spec/support/utilities.rb b/web/spec/support/utilities.rb index 7a89d9b23..8248cfbae 100644 --- a/web/spec/support/utilities.rb +++ b/web/spec/support/utilities.rb @@ -213,8 +213,35 @@ def go_to_root should_be_at_root end -def should_be_at_root - find('h1', text: 'Play music together over the Internet as if in the same room') +def should_be_at_root(options={signed_in:nil}) + + #if options[:signed_in].nil? + case Capybara.current_session.driver + when Capybara::Poltergeist::Driver + signed_in = !page.driver.cookies['remember_token'].nil? + if signed_in + find('h2', text: 'create session') + else + find('h1', text: 'Live music platform & social network for musicians') + end + when Capybara::RackTest::Driver + signed_in = false # actually, the user may be signed in, but, we only redirect to /client in javascript, so RackTest won't do that + if signed_in + find('h2', text: 'create session') + else + find('h1', text: 'Live music platform & social network for musicians') + end + else + raise "no cookie-setter implemented for driver #{Capybara.current_session.driver.class.name}" + end + #if Capybara.javascript_driver == :poltergeist + #signed_in = !cookie_jar['remember_me'].nil? # !page.driver.cookies['remember_token'].nil? + #else + #signed_in = false # actually, the user may be signed in, but, we only redirect to /client in javascript, so RackTest won't do that + #end + #else + # signed_in = options[:signed_in] + #end end def should_be_at_signin From c230f5759210e2474ea6f56dc5d5cfca4cbf13a2 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Thu, 9 Apr 2015 09:29:51 -0500 Subject: [PATCH 03/12] * adding in required images for VRFS-2868 --- .../assets/images/web/button_cta_jamblaster.png | Bin 0 -> 6419 bytes .../assets/images/web/button_cta_jamtrack.png | Bin 0 -> 5782 bytes .../assets/images/web/button_cta_platform.png | Bin 0 -> 6184 bytes web/app/assets/images/web/thumbnail_buzz.jpg | Bin 0 -> 15528 bytes .../assets/images/web/thumbnail_jamblaster.jpg | Bin 0 -> 18496 bytes .../assets/images/web/thumbnail_jamtracks.jpg | Bin 0 -> 25282 bytes .../assets/images/web/thumbnail_platform.jpg | Bin 0 -> 28139 bytes 7 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 web/app/assets/images/web/button_cta_jamblaster.png create mode 100644 web/app/assets/images/web/button_cta_jamtrack.png create mode 100644 web/app/assets/images/web/button_cta_platform.png create mode 100644 web/app/assets/images/web/thumbnail_buzz.jpg create mode 100644 web/app/assets/images/web/thumbnail_jamblaster.jpg create mode 100644 web/app/assets/images/web/thumbnail_jamtracks.jpg create mode 100644 web/app/assets/images/web/thumbnail_platform.jpg diff --git a/web/app/assets/images/web/button_cta_jamblaster.png b/web/app/assets/images/web/button_cta_jamblaster.png new file mode 100644 index 0000000000000000000000000000000000000000..8650fe3225c8e0343066648884bea91a9e2133e3 GIT binary patch literal 6419 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 oB&JuAmw5=Qo9#k`}Z;!Q$2b03J;b9smFU literal 0 HcmV?d00001 diff --git a/web/app/assets/images/web/button_cta_jamtrack.png b/web/app/assets/images/web/button_cta_jamtrack.png new file mode 100644 index 0000000000000000000000000000000000000000..998d89b75dd8c4aa80bdfcc3d57d5ec2865d0c48 GIT binary patch literal 5782 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 literal 0 HcmV?d00001 diff --git a/web/app/assets/images/web/button_cta_platform.png b/web/app/assets/images/web/button_cta_platform.png new file mode 100644 index 0000000000000000000000000000000000000000..dba8d499b65a0a4cd4f3518b7293fcc4467e8c19 GIT binary patch literal 6184 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 S#M}MTy9a6Lt5>Spg!~VjdT3q% literal 0 HcmV?d00001 diff --git a/web/app/assets/images/web/thumbnail_buzz.jpg b/web/app/assets/images/web/thumbnail_buzz.jpg new file mode 100644 index 0000000000000000000000000000000000000000..737a1787355f0647c55f49266337c7bf79a622a7 GIT binary patch literal 15528 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 literal 0 HcmV?d00001 diff --git a/web/app/assets/images/web/thumbnail_jamblaster.jpg b/web/app/assets/images/web/thumbnail_jamblaster.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7eef7d267c0309da4980448074d1dc627923c236 GIT binary patch literal 18496 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 L@gV%t{;d2T7>lYX literal 0 HcmV?d00001 diff --git a/web/app/assets/images/web/thumbnail_platform.jpg b/web/app/assets/images/web/thumbnail_platform.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8adacef5979021041839bd8af088860d964ddda8 GIT binary patch literal 28139 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
  • xb)LGua&Mc2&)#lY*<4zH$ZiSM3YJ_<<_E|yN;c`Vt53Fx>7WfK2{zVGT`~Qr+UP5_6Ad;eKblO2 za^`2CvvdyEs*_V(w5k2+S|(dL2v=my9y@F?$xA*y7V&@x08IBu(qo2&Z*z568(oyH zjw-we-{6JK(r`t){;>1DQ~?tjXh&;Z+Mr23yOwO2J2cTy!+rY}iZ!=OkuKs;Ede^v zS#3CNpW~3V?=7N>O3ZI1@j>!F`>IhSZkd!m&a9%b^liqaD$6~(Qq>(9amE*yTlhW6 z?3kyh3_3GxsW+)~IP^|%XFl!_>uUS~;B@qIbPgZcM(YC~`InbO@P3-#3m z^&5EC+g}vqEh@03eP%l{Z=1bpkQxdTE;~g?4(sD6&)mUT=`@N`3Z5nsF?xOojuoj= zbMoHAjHRw18#fo;*i}awU`cP4QP17NDDPwUS(6Cc4!I)H8E`*9Db^9j3(b=U;mHk z6GsM}?KTe6QcnAkR&Qw`aTEmwjLS}E%7IIm=4Z%Bkgk@*G2}L&Q35*`Q6s6M_6mve{U z)mYJ^SHL2_u*l$4Mmni}W4M83fvSnxGjubig}F3U8t=8?{*#Ehz~AAr*lPwKU=C#y9Ifn(g5RzgC{Nt>gSDR8wWTUIB)ve^%XLsEYG7 zr%AlxBEQwJKDQ27$_Cl&He~D^uj+G6x?D4P9e?L;MSCwVYL!}KAv(P}kjJ4j!h0CK zYb9V+(!S!dm9K@Q@zGs_kORg|v~{F7Xdjq1gVQhLn%7V;tyu95SD?0Qobl`LiH2yu zm<83JBws%d26W@^5pRG98PL-21Pj*OnyyoaaLnl?I92-c&3?`A}#z$}>XQ+eaz1T~siYGO=9g zS|Y3+KNbk4juOdD7-lj^ed+R&vU_Q{mPrjdz4qlGe+qt`l;02p)nLV3Aozy}s)2Mp zJ`K)+u}lR=vDHY+VYHlt5Ev#V7z*Ec|C@k*IEu;2wlrIky3S~`8cQ?fQ;7imv(`Y#jE>PNQjgLg`{+a-q;A^|dj_hA&D3F>+`GC!e`bUTU zGvZCjl)4NqcB3W-eJTenEa&%4ACX~8I8kT`Zr{GmwuIV}CKkVNDb4WvnO78kH~)!A zv!$z4M{514ND@;b{^vR^Lv1u%gII9pFF7qaiFQLRw?=qqJ-oDyA*08pHR0n>0*iPq z4t2b0HZ>dgcr+xd46=3wqzgYC1hBtdr-Prx)Uc+FL zaekm6^c=V4+(sboCUo9{{qwVDAWWMJ;!zqn@CR!HsLMoUK_UjV77F+-&XxeCc9c_L z^4H)ALHlqeY_MYg_52~{i4xka-e~%rR!m&lxOo7gp3x{?+UM=a^Rp+#+gQ#~gNAs8 zb^cx+H@NoYuRSbbQgID?eswRh0d=%79Lym*+bGa7VUvocoNx^;jH}8D2o-*$u+^kz ziyTsX!|~#Uv!US9+4Au^SxytU&PILCq&R+c7mMsj3@Vh@O0dic8)<6Ht+0J)z`=1YXN2JGEJTUcpWN5GW8E!e6^wqXOBw1 zWtlLHXtprMs=hOCnQnXzft+hk2XsOMN3#%za0xA|AJf|s#pzUBN2-E;bMWX@35EXB z!nNB)YRJ`++4KC0NEf)m4OGRNLo2xmg~cTjT2THlNpWzg$bQdnuV0`UttZk74y<0+ z8xFLE{?D}B4m1IOl zF=RV@&F_cWIb5zOW{~MEM->7kR4kMC>y+^}hJbh?_LxM%PRQ=F?PnSbx_d>?KoN>p zkZ3VB1>&$)%NWW`wWSj;r=cW?=t`38^{{aoxZ2I~d20C!w?VKB&L2cfE2oGv7tg1C z)#niO>{*?Rmm8Z12+|=%y1$O-Z-E;!A2;F1QAkIWFIfkq1i}M_jkU=a$a-&ny?ptk z`fO%`)*+x9C{Y({md!aqpkt-|XX>-<>I4t{H@$v&ijI_6!i!ApjQ9cF#dEokPR-jp z6XbQLQ+1EypS$l^D|Rc;IG0D&T|+kQ{; zyria0D;}G9BDZKNCwu9X+Wk72rTAa^E9uyR(CI()84XH-8)yfnbrfPI`&1k`7qYeN zD@}4^)5yPz6=Sy6qA8^%2Pt_$rAyayXG;jfBj6?0G~9BR4MSq9#CB-S#DiKb%k5W^ z5Y0_?K5V8tad3Hx9whUuH>q!Wacm<3FZ=`>?-M+(WzIC>odc@MWs z({GD{Mh0iH19ubywYSf$M6W$$mqV_M&DmtG@A<85{TKf@)MjTnXqh^2RU-~-6tHdK zH0wq@H4X+E-3Zw{`jmL-@a&c}d1^j&$el*4f2X6P z@c2%s_`C})ue95E$%`w^OrBN340K<3DW+i*JLZFmpcTY!$ip_ttTzO^QDG&*m8 z^h*k0oUj!)0?9Iol~KZ`>SXq_dWwF~=W1sh5d7?uO#fjeD~=!9%gr?!?Su0b(V^S# zxxBZrriWNVS>V*sD%$Y(Z#Xw~K&R&G{nVVXq6oHPPc{Q))~`j0U8rluF;|sbr#!8F zeFdcFV0=xn8A{njx|rd?mKXgp%=}%>82*A~tq9F0h%>qfaagmT{Ks8^unoFK&(*Jr zSAf=FFtV2LP1tew%}lo&im&ll@j=`mgnMclJIPFq1F0Mj4HKuYiG$p0k(G#V{Q8+@Or7#)s3pmr4jbYs40bzW3x7 zb+cY)QXw8sJnrMzV*@55qH&e@DVl4_mv4*Nlh+Lp4+dNa<`@VSwBsXvw~*aBX|- zTDVcjueqps>!r$ddx~Nn%1WrZtE!cPvwT+X!#CDPKU55tPN_jaN0~xJFn=`43bQZ5 z@vuCioXWx8<@mK_U(XR(YG=!oJgeY$KD(RT+wt;{D4LFMp;4QYC=?v_maI0q%kAp! zeIU#NG8%+BYE+^1GP+G!g_-Pa95R8lGA5H1iw!<*Fhxh#NVzLFWd{}+Vr5=2?V%fM zEjhJpTlst6=HoEVs40?Riqqs|mcmyR9)w{S>FRY`Z@6uY`_fFj^LC|`w5QBEBh;?#>X3NNJg;xLKge?b1dI(70v~Z zmri}(YY${D;i(rsnBUx#?Vja?l?FEspd0go^T53o>tI)w=Vpr(Tkjk0!sRlpR5@|R zzz<#rCERGZDfOWHNikDryrql6riHTGJm$2|>CSp;hGX9^cVQLRWr|~ z1{G6b31Q(yrfAG%tG&Q3vNcJRI(?*ZEu(uoqBY!6JRZ^2sWTw}5CSn39E*cYPP>x3 zxhkFTnms37m%18KsoyO)6{LB4Rjs=Jtd_;66_xw^vvAulcP#$>>HaK@hhIVB&a?n- zc8WoXjx!%Bsm#N1u*Gw;R&G9v*O7S?l*x^|=29yw_2z6PZ1k~W4}u-MdSVn0b5mQu z_b0g>&K$sQ>X`~)W7s$+CCLI^r#b6VFJVnN)gm(uJ2qv!>(4;=+9Gx>S~RmU+*aii z^OFC=fg*V%sx_>WeMnR4?n&o*g9J^Z+b*$_BHN@Crb882|HCXC3_4T`_N5;|ZB=O}w`a@=NO5 zcVq}$rvgXtrI7O=f!-@SvfohwBEJ?j(_aB9#=D^;mkBr<-Nl(!lwVeh>LahhX)_gg z(oemQRGa3Qx5D}xvtV4w-j5K7eNyc>$x$K@kMr$PmcgV|m9ca`G9VGzSdfm|k}DzK z3pTC%BXeY)L{%>XXmB*X-2rFn+a6Cj7rK&F?eP=rnc;m!yIctmi@%-f`Q&oGKbB(! z(F1{uK5)uUyq|Pdqv;C!A}3gt@-$*@w8=gWpE2F?j{hP)?@*gv;F~gn9z`iUV+A%T zY^mEKIShc;J0~0ecJiY%i()-(sa{mPezO1V+xBhX8Tn*%%Lb#XQ7@x&yQUKHgf34^ z*(~|86N{RVyEKJHuQcQ2?c>`W{VxR07?`F8ETF*NQ}EZ{T#&cTv(z|y$*Bg~O4abx zb9W*g8QSj6+fgsQ3jLTEq8u+Ay>C8auK+!{=>Xr(CR|Ksx-Ej|%2K(J8s-I_x0?#0 zpWp5xxw#Xiifu{QsfZ^JxN3#&Y7s@I`%r(0!<8-#KvN3}2_-a>L`x%OAmoR~lhBIz zLSX(*;3~S;K=XNQ!K0UF<>%P@Ct!P+erTk6s2ze8Q6qfQ3pj=8Ggl<$O`!J_VO=-7 zqk=g{uR1vqIZ8XWcpF%rR=Nd%X~cHoB_Qb=#1}FSyI?d()EKPdL{0xmgj7Lf?A)D$ zU#!r#V-!O$UX^Gi+fh$~a=n@}9XS|hck zI}v9D*Q`Qk9H|xy_G+KG67Nvi-1wOs2CWutc?vh&K&S2(+N>10K#5<5(L8s6?WJ9{Mk9sjUL4&yOYDuJYP;+>VE_A^h^&v8MboxzDp2nZ zn_dBauK>=h9|H`-#jXwTKUCo4{i35Ea#L{Bc!q7wDXfqL8Vg5Ok~B}|hw&e(j_PEi z(gX$u_I4+R!p6>&Ig%%dJj-4I?2#H_SyhF*5~Lk!Z=pb&)<2?QNMG5~(484D z^mgzS*N8Oyt3xD{Yb)O*$YM8*Z+(AgA|R$h&^LYox|=T}I8`M$TzykN*~2{{811R> zJTPlf$HqM4u76AZObl`iSmK{J3GOC~uh-cqex`iO2ARn7yB9f#-#hAt7d^6Zw6JuS z(H7edv}7UpnEY5;Wg7ioO}%wklVKY+J{3h!KpH{1lxB1eq;r&Xmy8Ap$tm4zbcb|z zr_!m^5SSn}X{B@HxA%R&-*r2Fv)aWkP-TClAKHB5PK26V8A zh+Tw}o;S;Lgkj3+Kl?tdhHP>*Z!+PN@ff#yckzdTN;b)lqn~@qCy8Nk5lS@V zOkuVe6%o(3A927N;8VngXc*Wt;6bk@U$d%Svf18nkoeY8T1qFij6LGz=t0c|IwN$jZr3HXBhRZeL7=n9lBucWYWQnw>>PLd}dqm zSz>M)g}KJO2M$E&ntZ^7RADrqC>xISOTWh_ml@Jme+lqdA=h<6*c?-@7n=Z2T)JY7 z?5qYy@r6@No-dB_ z-qSmNgC7DIb&R39O3=ffq>G<8ZxV#Fl)ERc6}AYTDYqkU# zPF5=RKXYz6*3){Zf>BP8jl_Le!wdGy9bqz1OD5I(nQ!wOJwIK`e3Aq;#w*z%zGX`x z(p}J$4?zC2d=V4oE}oOLtX&9xW5maG1s3mZ_P;cBHv=4_U{Y5em9rb~=GGF{IDNM; zN*T!H+*<2fR=5N|l+({d5g`O|Wl=~FdpXWQlC6BFg-6A}?@3u;$_H zkrefIEn|vQ_7lsQ?1Wlv8T{2Knp_NbWBd4@eu=g_zeeV{!C)drMav*L^r_qEIkve= zI*&5Z5ng4_+sfN|ZG$OCq7=Eqo9&RZuFS-bIX?a?)DbR`h{IWyDeJ_T$EKac*DOqU zw)gnC7DGw7!a^_Y6NNlp(6;gxvnP0#4gr6@Q*V83tjSU2A*m%lJGstmUJW(){*I!LvcL;w}8p!u*+F0|DMOy*?c2i-Q=alS&S})v zVl25FjWf`vL_ zXr=8_E7`6L*JQrNFxN-{BRi&QxkEq0mQ)J60&*3n%VQd}NkfiV45|XQfZ5NF@smYn z4p@?>@F_jt`P`n;vtL?GJ-UqU%edRNDf(q3b4K78yVy|ISV#JbJOe#=NwAOypN-(Bi!=%Hi0s`%2> zB27b*xI|4qN-HhTC+h9GHKb#$jhz#oser`<`7N%nM}qAk-v%_(f*DB!Z|mhN{J6JD z1tjQ445j``_(A`vvsQ=RYOK%Mu+~{9sBS7L45~cI7RA)=92{zD~_q8T(F#(E3wV zl$d;g+cxt9!)YTp;`LNE97-O{e0?72E_)cp$4h)#8(n_wWSk^wl#uJK-&VUTcL59i z5w`-_0xC_4o2fr%-OBZ|IpqoPAMhuZQe3t!E?CY!mP{}EJi-P$rD++^5(A>$z9X1b z1wWcCJ&j(^AfcAgHhl8zLP1mR6~U31K%N@e08ijyK*bH6@yP~_L8qH$K!Fmgc^D_J zD#NS9Ry9WAM6!-2d=w7eHgxa1+w!#%X0e3M_9GaKJ`RP-A_A9Z3m+u+H18z?m zoqYP~Rdx}aWe{PXXR`>h*xh#kjM$fy>Xp{0Ld_4KIo{*fl)l!6JOiJbc$1g`lL{*v zYQ+7Mw8ATX5*DAmwB=vHn^a)PwupJ$u)s}^h%hoA(do8>8?!Ox?=+lgs`}xAC8|nKg>?-morDF%w9mNWcik7VEA#$$w39Ry@{ti*Rc#Lyn_igCB)Md_6$=F_6 zgQbX$ot>@`m`w{Stb>l9CLe!-^BljX)NTrXp*y<6k;{z6_xn+ePQ(^$PTi|69%bO{CQNk-6tSt zMbY3}{A&e5N+*1o^lhK`0ALL=H~h-hF7f=e+^0#0O2dQ#d`va|jU2`Uh$P@OZ!a5k zWllVjfhLgkU^2*=PXFG0p}Ys2PGG=nmOf=y-M#c5ed(~+%7p)Le^@tr+jR$N`;qol zO6Z?=-o8>*|LKX`!pa-xwSxWt&AdCti7on=bnr=PM%%y-KEn+{epg&#L>suBI%yL# zydoPr9(FbBTq+&Ew3m!{GN5Vt);ji^cMp-)j~3kKp10lLqph-U6Pu>1R^MMn%4Ope zXdS1(JX|F;?+o98%5Q+zHjlpue916Uc6KSd$#GwP2^$fb73eOZsbsWg6!R^pwaFE< zRY-Cwf+ol;hbXVPMD0iuCyzQ8Q58hZxft(V(0K1NQyxfE|-F9 z(g?$AGV0?RrSF0ztB>^@Xq>`W2orh~aY_+%F^R~1Ph;Mb`WhfEa$+O4BYi?S=kGa9 zjic3tZ zWF&1II^Je;vZ)=pI769N=KSwTnk|iFh!VNuD~|BHFL#GQ6af!*-g?k9Vi!O$L zyT+CVB@k?Jru3yZ!P?wcjYZ_fd1UA>SbnR%uMT#_Soo#Obj&s9YO~JsJoFc?#_w{6 zo0SuVRET|E&fhq>3R*`7fG}cMnkzDm`W@kRzaTm8m3unbb{mj%jxA87;BW$qtnTmys(NX^sMHEb_Qa5Y1-n=1ookI|JTlNavkE zy{&ZtQme4zXnbDUQv=w!%M&@Ej*BEZ_H+F;AIKK9sZmL?ywmBTpEBTIKg{Y!xo$Oh zl{1+#N=RmjXQ=&7@m#yZdBjr}%SJZ2=`3!0E*t{92dI!e;*MmiI8}Vi8jBUTN(m0H zl!jL??Q!5yoIG(CPmvJ4(aWjg68$14>A4Q$^_UM_*g^yQ&8Ny0%8{LTE@u<^T&BcY zEHLM6Z`u!Lv_e59dB6XBj4*Ab{VOmZ*np}N{H0dm2nACpf}vf;Khm+{YOXb^ z-)+2)b-HGJ>98U!V-JwAy+Jw|g16bbTg}g{8e2MUrs6i;;p2}yEJ!s$%4^S~@Muo$ z5U?eqx4W)|PDX70S`97}BHuaJ;dX-$C*n;s!`oHFo158Zbe_Q;YXHrOU;Xq*h$hQ( zgA~W^Y3r|k-1@lvu#vfTyjt8Yl6kAl)J(!X z@9NL_FgP?Rokb}d(Ove3mTu9wIm`MhvsNu+jSpy(itF_Jc%6~vx~a-&PdaxvS6uWx z2|0^F{G0aad`-Jo#C7cO`C)-c`R-Nw>hLNp+`w@hQCo`bG*TMc9D->)yLjJHs_mdL z^TKNOd5z|PrmUoZ2N_xMT72uYroV(!qL1^UUAm8lBa;J_YMp2c^hBZ*9#GA$n71EO z)l_JnlB4Gc4QqPbPN3tU4Dp5RpR#2W6rtsz?m~E<6Nt0{7QZ^^DS5K39^)@ALo+1H z>X0U5)|b@b4X?|?sN!XWw!?L6q&KIs7Hi1cQm1MT(OoM@NB&eEviMI(+UeIe`6=ew z2+5v5vA;l5{?4t!zWSsl-&y(}YaJ9C7r++*D%OkU#)-tIA1cJ({z;K%JN-6K&wT@eoT>+no~v|-hyq6 ztfC@04!unnIjk+;uj(X)UguOsf_4pT^DqBu7z~C_rR5u$s3q6TJGg!P;1_?C{{h1J z)q`e$%RZ4b+GTK2Bt=u5{3X(0=P%ZA{@=HIn)StX{6bib{&c92Q#i~Z_&Dw? z^OuGUY<}8!JI@bcUmu62k_+R6lhY@DU3vcof0{X00pQyClFQ2MkGB zXyH@{(%XrkbE-!Lh&j3Mnrt3L`ufqTrB;+YkKEXWi{Z;Z6yh!je>nJA4t?$5KH=`%$#c#KX(kpqG z^{=FiKZ^VzBEQ0&;3#;b<%x{akPB%L+1IbAiRhOF%hxJ@(O^C~L4V^nz-+$fEjw?_ zml&v$&Iq23Lr)W^CnwyVG?lL)`N|Wv{N>zEqBg_v2K!R_V8@GT9qvla`|+G}wIIH` zq*eXM2_NJ7w$`>7QLB%TNfy1$x$SsPx|5;RXcwLm-wLCt+B6QXKWD}E*64`&x?VDd zI-!*y4zRXYwAYMrB>ayKXPy+ym$)5NsA z0IK-fuF|2Mfhu!FZH+4WXg_Z*32N)=hx-aMfpyMl%Tq0j8O142qL|29X!japnk@AO zCbY8;?N_vC537r23sFr9WPv;k)#?;2$b~5KDX1efb^^sgC9~SP7Q@l*Ild?`;hhm{e&j<3}C}rn9KTc=a>VEG9O3+(z zlJ6tON0SeyYMb{}G3jgslNgjf_MBEkOY-XlwcO%AbPp?(9NP|}pKJ~1(NpjWy%cef z7LvxjPXCG0B#>{&&2heQ*rGYL)|b|wZ>D+VBz{vhwXzsT5+(vpTNP^^Di6Ck+HHcA z5!$@ZcWU))*%PW1Oja{Ad!dKO_PQ7jV0vC9o4~leQ|o-V@VD9G*PgG(bOeh@TCqaP zZ1=}{$3QxkZTS@*1>qC>>U|VVJU?sdX3r*T$-!nAgrBkIs<51+_Q~3~24K=m9d~}) zGHn4lg{g7_*mzcAC6R6e;;ch8DdPt`EBfMDmt+bz! z^W#$Pky3n(iiYFcdJd%;{|I z>usFjT25oWvJD`x)Z=mfUe>AF);CJsE+eB#gyP#DbNz3I4RN1V*}Urgax&bG-WOAF zyeb!?$7wuP@|^pKtE@$T{zU1U)Gqd8O4|pOdM(`gM=boXP(Housl{525S!x3Z#pkI zUU^ul9%$aaN+IBfwYB*1%HAa;&xz-dvhI~XF-h7fzGO}u2u+w902|Lp=3eXa;t8%u zysVgsHWI%jabKQ$({^%^=+aXY-)wn)wjp~HA>17O;>P;(!_PmtpOA?a#UqlKI`axs z=oe#3X0LU}PeB!I$u4_P@xhq2kB`V@6DR(1qpejoRcs(d4tdgcut+jN{hFs2Sdq^z zER$%H)$5n%vsg=&7{tvy9do}54bfG?GYuBsUgcB5&{B)v!bs%509`BBKbCKMB2#-RA4Um@Ek z$*iYdnzP%$7&AE3l?Y`PUFunF4VY8)a$A`R7-=Cs$P~|83y&ceV5HWw2INN@uZf7+ zsSo49W{5-k@YL#(?Ax(J2o3#txM=vtt8SkOL=0nf%)2^64FH8s4NuE zWYXpYsc44XIRrcU(#uZBZ}=S;x{vy`tEa!7a_DU=Jsk1u96340+hfz4xr{f4z6cq}(Wze2XQ@m0; zbYP*QdLw-HohjJJan)DJ@Q7qUxs}0ek0hj@g!7vq8S3nkY;ufhmMvQr^(ck1I1DCEXP-|@ey1L?d&N^t~j7ti63Ba-p%qh-+G=C>#kDM5B zt!j|N&D+|lT39rB8AY(o8wTr7eCYDA_}+8o7bW?AzSFCg#3xPjuFQJmZ{Cpd-iZud zt+PA^G@>O=ZA<{gGbZM9_8ch6+YBH^)PZSk@}LBSB`=z#F@w9Q=B}8 z6JB6s^wcR{T;5#f0~kd=!pvn0Opwvyefg5Y5h0$64*Q~}vPwZe?gQfgv1JGFYl<7) zniQ;?oCx2fD|za%`mq%R?1-1*{g6sv5g;{ljuz00U1)AMa=J)vzu=s=e{~F>njT4^ zQ|YYM2^oJVimbcXwiPj}PKcT}3w8Cden*x?$E~~g;CT_N!%>u=)ur#~oSb(M}@r4Y@(uPV)*yGMm?QN+JJ z-*TzwwVn!1k{QWlfgW8%&P5M*LlEgGHTz7vrVb~kmYw2%ZZIjDZTKr~Vba}X&e*B6 z({K&*ACylbPFAC^NNWymc;ZBY*{goEZ1n~++^4XF^*5xsLI?eHd-t1@`vZ&vc;wQ6 zml!wgkWK!>QpD<8Vx(5p8}dE;tiSIhD#ktyrr^{kGj93TEo=9p?+8^hR#eJTVSzS- zbon0-!{odLmUih>PTOY#d%R23JX^nr5zH%mCr^gG^V*(E|Mv8FY_Eaz8G*lZfocqd z2qn}VdbSnW;)Nbh zyZyZ?qaQyAL~pJS0_;1)=u8HF`D7v9L#)O z=)`52t?6relz`ab=P+@i*jAekSzh3sJVD0A#gGXJpQDI} zR=`F<>fM^@Z81y>v>f; z?~a2xPo^TW*^sOVCl8~|#G#N~K4YYrBz0QhIj(BrY6QOW5p_zb8W)Q++?9)kNI>~k zO6u^)`sp!Jxv5B2yi(Bvdmf(`?(!AqN4`M&p=g7D@#M4G5jMO!LzNwVYfMK zDNP-7h^!dvciS3%Z%;CZB<*ULj3J|}d*F-F1IZaH_Z_*Gntmazk$56k9$??|1dSZaM!QqNMSRM45mK6<%d@bODZ({^ zZ+A-QbjGv+ZN~3D6_&VZ;~o%k!eUWKXV!{YN5R}V4_|06(iQh8;~r2m09+SUc4ZSyzjRa#gvNksUHA+j+hoS3$t?&Jc&r0xcm`mcBA*glfSmQm`Fij z>lX{DvfkW9#O4t*iQePH?HV?I3)u&BKaY9wt5zWT#%2@#kf|cucn9g3-E`SXXJcx@ zp$7+`1QQLP_%?Twf)DkyK6IkwNz3YA=4aaHo%>c&$AzbX#WbTb){m9;Z|kEfk_c?F zeY6J++`-_Ci9uuaV0_}Pl7SDA76duM}5oj9+X^WuK&?T+Yu@7rB?Hp9jC zO!cD3W8FMug;`3`mG;;L?f#uER&A)Y2zq7OXM%7}lArrF#$H|MFzd0dyE&Nj0cU&c z?)!!8PXgg=vg@`v-ymP2YGgT;csZI zG!GaBZG3j^B63rA`>=b6s7zK!YMg#QF*f}=-vPYcytdbOfZtQ?`ig3MTlSa`M=$8g zCY>8ie-LWhd=pHIedyP7P|SR5Ytv-=^U@ypZB*uA{);V*H|mXIO&%^p7qY`?*=hs5 zZWkneZ5d}8Ojy-fztr_Mmvt=t$stt&zldC{{%*Jot%lCe#+TS_#+L5@J-Y(wG9h$!FsCpzO|YsPB2$hd+Hs_kg7*o-;f5fQ5U& zt|@HQCVgB7DS|j$UUj+l*NIp|$GL1^oI~)^w;)Yhg2b^tU6C{&9V;+i4Zvy(H8SE<- zuuDwe_uT=p-2>*a15Ql=tN?`}#zq@cf|=yqYKES`v0BMnSnWa$Y*FN6aiHM&K|)JN0m-YpW{iZ;v17VKi3TcWEf zL~h^lLEy&CA{t^b{{i1PSK+_4E9Notf0jCNUXb9>6IUjO$-GdTTODv_Enxo_|h%VFhLW^1op4BC=&6WjJ)XXqi2A-o8!v z-SmHh3EuLu_;0YcZxB6sS=OMVx6#OIZgBrrM42w82s6nqPD}Z}d~fN~ot4rylgX6O zr@(Z$p;|2VKnEKLFKYU06|5I^&<^c2&4)b_<8yUK3D}`I{gh)L_CF5M!+u~h4vgkV z-?!_enV=?81eyAzKzRm^TB;N8( z{6BYO|6iyownX>j-PJhu#5X;*j)9hmVXoMhJV?)e&og9UO^lFm8zj=6S@PjYRwK|O Tltg;2&3$DIPM+_(U;6(5pf>e` literal 0 HcmV?d00001 From e42b926a5e70e21903109fd00f02045609cd5d99 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Fri, 10 Apr 2015 15:19:08 -0500 Subject: [PATCH 04/12] * VRFS-3028 - switch to using Recurly Adjustments for JamTrack purchases --- admin/app/admin/recurly_health.rb | 5 +- db/manifest | 3 +- db/up/recurly_adjustments.sql | 24 ++ ruby/lib/jam_ruby.rb | 1 + ruby/lib/jam_ruby/app/mailers/admin_mailer.rb | 22 ++ ruby/lib/jam_ruby/jam_track_importer.rb | 3 +- ruby/lib/jam_ruby/models/jam_track.rb | 1 + .../models/recurly_transaction_web_hook.rb | 39 ++- ruby/lib/jam_ruby/models/sale.rb | 280 +++++++++++++++- ruby/lib/jam_ruby/models/sale_line_item.rb | 13 +- ruby/lib/jam_ruby/models/shopping_cart.rb | 90 +++++- ruby/lib/jam_ruby/recurly_client.rb | 129 ++------ .../recurly_transaction_web_hook_spec.rb | 20 +- ruby/spec/jam_ruby/models/sale_spec.rb | 303 +++++++++++++++--- ruby/spec/jam_ruby/recurly_client_spec.rb | 50 +-- ruby/spec/support/utilities.rb | 8 + web/app/assets/javascripts/checkout_order.js | 99 +++--- web/app/controllers/api_recurly_controller.rb | 106 +++--- web/app/controllers/clients_controller.rb | 1 + .../views/clients/_checkout_order.html.slim | 6 +- web/config/application.rb | 3 +- web/spec/features/checkout_spec.rb | 10 +- .../features/individual_jamtrack_band_spec.rb | 2 +- web/spec/spec_helper.rb | 3 + 24 files changed, 860 insertions(+), 361 deletions(-) create mode 100644 db/up/recurly_adjustments.sql create mode 100644 ruby/lib/jam_ruby/app/mailers/admin_mailer.rb diff --git a/admin/app/admin/recurly_health.rb b/admin/app/admin/recurly_health.rb index 518648403..aebd21250 100644 --- a/admin/app/admin/recurly_health.rb +++ b/admin/app/admin/recurly_health.rb @@ -2,12 +2,9 @@ ActiveAdmin.register_page "Recurly Health" do menu :parent => 'Misc' content :title => "Recurly Transaction Totals" do - table_for Sale.check_integrity do + table_for Sale.check_integrity_of_jam_track_sales do column "Total", :total - column "Unknown", :not_known column "Successes", :succeeded - column "Failures", :failed - column "Refunds", :refunded column "Voids", :voided end end diff --git a/db/manifest b/db/manifest index ba11edce4..6634d9381 100755 --- a/db/manifest +++ b/db/manifest @@ -274,4 +274,5 @@ recording_client_metadata.sql preview_support_mp3.sql jam_track_duration.sql sales.sql -show_whats_next_count.sql \ No newline at end of file +show_whats_next_count.sql +recurly_adjustments.sql \ No newline at end of file diff --git a/db/up/recurly_adjustments.sql b/db/up/recurly_adjustments.sql new file mode 100644 index 000000000..d86e6ba89 --- /dev/null +++ b/db/up/recurly_adjustments.sql @@ -0,0 +1,24 @@ +ALTER TABLE sale_line_items ADD COLUMN recurly_adjustment_uuid VARCHAR(500); +ALTER TABLE sale_line_items ADD COLUMN recurly_adjustment_credit_uuid VARCHAR(500); +ALTER TABLE jam_track_rights ADD COLUMN recurly_adjustment_uuid VARCHAR(500); +ALTER TABLE jam_track_rights ADD COLUMN recurly_adjustment_credit_uuid VARCHAR(500); +ALTER TABLE sales ADD COLUMN recurly_invoice_id VARCHAR(500) UNIQUE; +ALTER TABLE sales ADD COLUMN recurly_invoice_number INTEGER; + +ALTER TABLE sales ADD COLUMN recurly_subtotal_in_cents INTEGER; +ALTER TABLE sales ADD COLUMN recurly_tax_in_cents INTEGER; +ALTER TABLE sales ADD COLUMN recurly_total_in_cents INTEGER; +ALTER TABLE sales ADD COLUMN recurly_currency VARCHAR; + +ALTER TABLE sale_line_items ADD COLUMN recurly_tax_in_cents INTEGER; +ALTER TABLE sale_line_items ADD COLUMN recurly_total_in_cents INTEGER; +ALTER TABLE sale_line_items ADD COLUMN recurly_currency VARCHAR; +ALTER TABLE sale_line_items ADD COLUMN recurly_discount_in_cents INTEGER; + +ALTER TABLE sales ADD COLUMN sale_type VARCHAR NOT NULL; + +ALTER TABLE recurly_transaction_web_hooks ALTER COLUMN subscription_id DROP NOT NULL; + +CREATE INDEX recurly_transaction_web_hooks_invoice_id_ndx ON recurly_transaction_web_hooks(invoice_id); + +ALTER TABLE jam_track_rights DROP COLUMN recurly_subscription_uuid; \ No newline at end of file diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index da4fc6a48..69718cdf9 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -69,6 +69,7 @@ require "jam_ruby/connection_manager" require "jam_ruby/version" require "jam_ruby/environment" require "jam_ruby/init" +require "jam_ruby/app/mailers/admin_mailer" require "jam_ruby/app/mailers/user_mailer" require "jam_ruby/app/mailers/invited_user_mailer" require "jam_ruby/app/mailers/corp_mailer" diff --git a/ruby/lib/jam_ruby/app/mailers/admin_mailer.rb b/ruby/lib/jam_ruby/app/mailers/admin_mailer.rb new file mode 100644 index 000000000..c4addb5cc --- /dev/null +++ b/ruby/lib/jam_ruby/app/mailers/admin_mailer.rb @@ -0,0 +1,22 @@ +module JamRuby + # sends out a boring ale + class AdminMailer < ActionMailer::Base + include SendGrid + + + DEFAULT_SENDER = "JamKazam " + + default :from => DEFAULT_SENDER + + sendgrid_category :use_subject_lines + #sendgrid_enable :opentrack, :clicktrack # this makes our emails creepy, imo (seth) + sendgrid_unique_args :env => Environment.mode + + def alerts(options) + mail(to: APP_CONFIG.email_alerts_alias, + body: options[:body], + content_type: "text/plain", + subject: options[:subject]) + end + end +end diff --git a/ruby/lib/jam_ruby/jam_track_importer.rb b/ruby/lib/jam_ruby/jam_track_importer.rb index 58138dbf9..5535f6e08 100644 --- a/ruby/lib/jam_ruby/jam_track_importer.rb +++ b/ruby/lib/jam_ruby/jam_track_importer.rb @@ -614,7 +614,8 @@ module JamRuby def synchronize_recurly(jam_track) begin recurly = RecurlyClient.new - recurly.create_jam_track_plan(jam_track) unless recurly.find_jam_track_plan(jam_track) + # no longer create JamTrack plans: VRFS-3028 + # recurly.create_jam_track_plan(jam_track) unless recurly.find_jam_track_plan(jam_track) rescue RecurlyClientError => x finish('recurly_create_plan', x.errors.to_s) return false diff --git a/ruby/lib/jam_ruby/models/jam_track.rb b/ruby/lib/jam_ruby/models/jam_track.rb index 5c8eb1d3a..b08533c1e 100644 --- a/ruby/lib/jam_ruby/models/jam_track.rb +++ b/ruby/lib/jam_ruby/models/jam_track.rb @@ -199,5 +199,6 @@ module JamRuby def right_for_user(user) jam_track_rights.where("user_id=?", user).first end + end end diff --git a/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb b/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb index 0139cba9e..1d3232169 100644 --- a/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb +++ b/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb @@ -4,7 +4,6 @@ module JamRuby belongs_to :user, class_name: 'JamRuby::User' validates :recurly_transaction_id, presence: true - validates :subscription_id, presence: true validates :action, presence: true validates :status, presence: true validates :amount_in_cents, numericality: {only_integer: true} @@ -68,8 +67,42 @@ module JamRuby # now that we have the transaction saved, we also need to delete the jam_track_right if this is a refund, or voided if transaction.transaction_type == 'refund' || transaction.transaction_type == 'void' - right = JamTrackRight.find_by_recurly_subscription_uuid(transaction.subscription_id) - right.destroy if right + sale = Sale.find_by_recurly_invoice_id(transaction.invoice_id) + + if sale && sale.is_jam_track_sale? + if sale.sale_line_items.length == 1 + if sale.recurly_total_in_cents == transaction.amount_in_cents + jam_track = sale.sale_line_items[0].product + jam_track_right = jam_track.right_for_user(transaction.user) if jam_track + if jam_track_right + jam_track_right.destroy + AdminMailer.alerts({ + subject:"NOTICE: #{transaction.user.email} has had JamTrack: #{jam_track.name} revoked", + body: "A void event came from Recurly for sale with Recurly invoice ID #{sale.recurly_invoice_id}. We deleted their right to the track in our own database as a result." + }).deliver + else + AdminMailer.alerts({ + subject:"NOTICE: #{transaction.user.email} got a refund, but unable to find JamTrackRight to delete", + body: "This should just mean the user already has no rights to the JamTrackRight when the refund came in. Not a big deal, but sort of weird..." + }).deliver + end + + else + AdminMailer.alerts({ + subject:"ACTION REQUIRED: #{transaction.user.email} got a refund it was not for total value of a JamTrack sale", + body: "We received a refund notice for an amount that was not the same as the original sale. So, no action was taken in the database. sale total: #{sale.recurly_total_in_cents}, refund amount: #{transaction.amount_in_cents}" + }).deliver + end + + + else + AdminMailer.alerts({ + subject: "ACTION REQUIRED: #{transaction.user.email} has refund on invoice with multiple JamTracks", + body: "You will have to manually revoke any JamTrackRights in our database for the appropriate JamTracks" + }).deliver + end + end + end transaction end diff --git a/ruby/lib/jam_ruby/models/sale.rb b/ruby/lib/jam_ruby/models/sale.rb index 63f42657b..06addb8e3 100644 --- a/ruby/lib/jam_ruby/models/sale.rb +++ b/ruby/lib/jam_ruby/models/sale.rb @@ -3,30 +3,288 @@ module JamRuby # a sale is created every time someone tries to buy something class Sale < ActiveRecord::Base + JAMTRACK_SALE = 'jamtrack' + belongs_to :user, class_name: 'JamRuby::User' has_many :sale_line_items, class_name: 'JamRuby::SaleLineItem' - validates :order_total, numericality: { only_integer: false } + validates :order_total, numericality: {only_integer: false} validates :user, presence: true - def self.create(user) + + def self.preview_invoice(current_user, shopping_carts) + + line_items = {jam_tracks: []} + shopping_carts_jam_tracks = [] + shopping_carts_subscriptions = [] + shopping_carts.each do |shopping_cart| + + if shopping_cart.is_jam_track? + shopping_carts_jam_tracks << shopping_cart + else + # XXX: this may have to be revisited when we actually have something other than JamTracks for puchase + shopping_carts_subscriptions << shopping_cart + end + end + + jam_track_items = preview_invoice_jam_tracks(current_user, shopping_carts_jam_tracks) + line_items[:jam_tracks] = jam_track_items if jam_track_items + + # TODO: process shopping_carts_subscriptions + + line_items + end + + # place_order will create one or more sales based on the contents of shopping_carts for the current user + # individual subscriptions will end up create their own sale (you can't have N subscriptions in one sale--recurly limitation) + # jamtracks however can be piled onto the same sale as adjustments (VRFS-3028) + # so this method may create 1 or more sales, , where 2 or more sales can occur if there are more than one subscriptions or subscription + jamtrack + def self.place_order(current_user, shopping_carts) + + sales = [] + shopping_carts_jam_tracks = [] + shopping_carts_subscriptions = [] + shopping_carts.each do |shopping_cart| + + if shopping_cart.is_jam_track? + shopping_carts_jam_tracks << shopping_cart + else + # XXX: this may have to be revisited when we actually have something other than JamTracks for puchase + shopping_carts_subscriptions << shopping_cart + end + end + + jam_track_sale = order_jam_tracks(current_user, shopping_carts_jam_tracks) + sales << jam_track_sale if jam_track_sale + + # TODO: process shopping_carts_subscriptions + + sales + end + + def self.preview_invoice_jam_tracks(current_user, shopping_carts_jam_tracks) + ### XXX TODO; + + # we currently use a fake plan in Recurly to estimate taxes using the Pricing.Attach metod in Recurly.js + + # if we were to implement this the right way (ensure adjustments are on the account as necessary), then it would be better (more correct) + # just a pain to implement + end + + # this method will either return a valid sale, or throw a RecurlyClientError or ActiveRecord validation error (save! failed) + # it may return an nil sale if the JamTrack(s) specified by the shopping carts are already owned + def self.order_jam_tracks(current_user, shopping_carts_jam_tracks) + + client = RecurlyClient.new + + sale = nil + Sale.transaction do + sale = create_jam_track_sale(current_user) + + if sale.valid? + account = client.get_account(current_user) + if account.present? + + purge_pending_adjustments(account) + + created_adjustments = sale.process_jam_tracks(current_user, shopping_carts_jam_tracks, account) + + # now invoice the sale ... almost done + + begin + invoice = account.invoice! + sale.recurly_invoice_id = invoice.uuid + sale.recurly_invoice_number = invoice.invoice_number + + # now slap in all the real tax/purchase totals + sale.recurly_subtotal_in_cents = invoice.subtotal_in_cents + sale.recurly_tax_in_cents = invoice.tax_in_cents + sale.recurly_total_in_cents = invoice.total_in_cents + sale.recurly_currency = invoice.currency + + puts "Sale Line Items #{sale.sale_line_items.inspect}" + + puts "----" + puts "Invoice Line Items #{invoice.line_items.inspect}" + # and resolve against sale_line_items + sale.sale_line_items.each do |sale_line_item| + found_line_item = false + invoice.line_items.each do |line_item| + if line_item.uuid == sale_line_item.recurly_adjustment_uuid + sale_line_item.recurly_tax_in_cents = line_item.tax_in_cents + sale_line_item.recurly_total_in_cents =line_item.total_in_cents + sale_line_item.recurly_currency = line_item.currency + sale_line_item.recurly_discount_in_cents = line_item.discount_in_cents + found_line_item = true + break + end + + if !found_line_item + @@loge.error("can't find line item #{sale_line_item.recurly_adjustment_uuid}") + puts "CANT FIND LINE ITEM" + end + + end + end + + unless sale.save + raise RecurlyClientError, "Invalid sale (at end)." + end + rescue Recurly::Resource::Invalid => e + # this exception is thrown by invoice! if the invoice is invalid + sale.rollback_adjustments(current_user, created_adjustments) + sale = nil + raise ActiveRecord::Rollback # kill all db activity, but don't break outside logic + end + else + raise RecurlyClientError, "Could not find account to place order." + end + else + raise RecurlyClientError, "Invalid sale." + end + end + sale + end + + def process_jam_tracks(current_user, shopping_carts_jam_tracks, account) + + created_adjustments = [] + + begin + shopping_carts_jam_tracks.each do |shopping_cart| + process_jam_track(current_user, shopping_cart, account, created_adjustments) + end + rescue Recurly::Error, NoMethodError => x + # rollback any adjustments created if error + rollback_adjustments(user, created_adjustments) + raise RecurlyClientError, x.to_s + rescue Exception => e + # rollback any adjustments created if error + rollback_adjustments(user, created_adjustments) + raise e + end + + created_adjustments + end + + + def process_jam_track(current_user, shopping_cart, account, created_adjustments) + recurly_adjustment_uuid = nil + recurly_adjustment_credit_uuid = nil + + # we do this because of ShoppingCart.remove_jam_track_from_cart; if it occurs, which should be rare, we need fresh shopping cart info + shopping_cart.reload + + # get the JamTrack in this shopping cart + jam_track = shopping_cart.cart_product + + if jam_track.right_for_user(current_user) + # if the user already owns the JamTrack, we should just skip this cart item, and destroy it + # if this occurs, we have to reload every shopping_cart as we iterate. so, we do at the top of the loop + ShoppingCart.remove_jam_track_from_cart(current_user, shopping_cart) + return + end + + # ask the shopping cart to create the correct Recurly adjustment attributes for a JamTrack + adjustments = shopping_cart.create_adjustment_attributes(current_user) + + adjustments.each do |adjustment| + + # create the adjustment at Recurly (this may not look like it, but it is a REST API) + created_adjustment = account.adjustments.new(adjustment) + created_adjustment.save + + # if the adjustment could not be made, bail + raise RecurlyClientError.new(created_adjustment.errors) if created_adjustment.errors.any? + + # keep track of adjustments we created for this order, in case we have to roll them back + created_adjustments << created_adjustment + + if ShoppingCart.is_product_purchase?(adjustment) + # this was a normal product adjustment, so track it as such + recurly_adjustment_uuid = created_adjustment.uuid + else + # this was a 'credit' adjustment, so track it as such + recurly_adjustment_credit_uuid = created_adjustment.uuid + end + end + + # create one sale line item for every jam track + sale_line_item = SaleLineItem.create_from_shopping_cart(self, shopping_cart, nil, recurly_adjustment_uuid, recurly_adjustment_credit_uuid) + + # if the sale line item is invalid, blow up the transaction + unless sale_line_item.valid? + @log.error("sale item invalid! #{sale_line_item.errors.inspect}") + puts("sale item invalid! #{sale_line_item.errors.inspect}") + Stats.write('web.recurly.purchase.sale_invalid', {message: sale_line_item.errors.to_s, value: 1}) + raise RecurlyClientError.new(sale_line_item.errors) + end + + # create a JamTrackRight (this needs to be in a transaction too to make sure we don't make these by accident) + jam_track_right = JamRuby::JamTrackRight.find_or_create_by_user_id_and_jam_track_id(current_user.id, jam_track.id) do |jam_track_right| + jam_track_right.redeemed = shopping_cart.free? + end + + # also if the purchase was a free one, then update the user record to no longer allow redeemed jamtracks + User.where(id: current_user.id).update_all(has_redeemable_jamtrack: false) if shopping_cart.free? + + # this can't go in the block above, as it's here to fix bad subscription UUIDs in an update path + if jam_track_right.recurly_adjustment_uuid != recurly_adjustment_uuid + jam_track_right.recurly_adjustment_uuid = recurly_adjustment_uuid + jam_track_right.recurly_adjustment_credit_uuid = recurly_adjustment_credit_uuid + unless jam_track_right.save + raise RecurlyClientError.new(jam_track_right.errors) + end + end + + # delete the shopping cart; it's been dealt with + shopping_cart.destroy if shopping_cart + + # blow up the transaction if the JamTrackRight did not get created + raise RecurlyClientError.new(jam_track_right.errors) if jam_track_right.errors.any? + end + + + def rollback_adjustments(current_user, adjustments) + begin + adjustments.each { |adjustment| adjustment.destroy } + rescue Exception => e + AdminMailer.alerts({ + subject: "ACTION REQUIRED: #{current_user.email} did not have all of his adjustments destroyed in rollback", + body: "go delete any adjustments on the account that don't belong. error: #{e}\n\nAdjustments: #{adjustments.inspect}" + }).deliver + + end + end + + def self.purge_pending_adjustments(account) + account.adjustments.pending.find_each do |adjustment| + # we only pre-emptively destroy pending adjustments if they appear to be created by the server + adjustment.destroy if ShoppingCart.is_server_pending_adjustment?(adjustment) + end + end + + def is_jam_track_sale? + sale_type == JAMTRACK_SALE + end + + def self.create_jam_track_sale(user) sale = Sale.new sale.user = user + sale.sale_type = JAMTRACK_SALE sale.order_total = 0 sale.save sale end - def self.check_integrity - SaleLineItem.select([:total, :not_known, :succeeded, :failed, :refunded, :voided]).find_by_sql( - "SELECT COUNT(sale_line_items.id) AS total, - COUNT(CASE WHEN transactions.id IS NULL THEN 1 ELSE null END) not_known, - COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::SUCCESSFUL_PAYMENT}' THEN 1 ELSE null END) succeeded, - COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::FAILED_PAYMENT}' THEN 1 ELSE null END) failed, - COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::REFUND}' THEN 1 ELSE null END) refunded, + # this checks just jamtrack sales appropriately + def self.check_integrity_of_jam_track_sales + Sale.select([:total, :voided]).find_by_sql( + "SELECT COUNT(sales.id) AS total, COUNT(CASE WHEN transactions.transaction_type = '#{RecurlyTransactionWebHook::VOID}' THEN 1 ELSE null END) voided - FROM sale_line_items - LEFT OUTER JOIN recurly_transaction_web_hooks as transactions ON subscription_id = recurly_subscription_uuid") + FROM sales + LEFT OUTER JOIN recurly_transaction_web_hooks as transactions ON invoice_id = sales.recurly_invoice_id + WHERE sale_type = '#{JAMTRACK_SALE}'") end end end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/sale_line_item.rb b/ruby/lib/jam_ruby/models/sale_line_item.rb index f90b460fa..0685244f2 100644 --- a/ruby/lib/jam_ruby/models/sale_line_item.rb +++ b/ruby/lib/jam_ruby/models/sale_line_item.rb @@ -18,10 +18,15 @@ module JamRuby validates :recurly_plan_code, presence:true validates :sale, presence:true - def self.create_from_shopping_cart(sale, shopping_cart, recurly_subscription_uuid) + def product + # TODO: beef up if there is more than one sort of sale + JamTrack.find(product_id) + end + + def self.create_from_shopping_cart(sale, shopping_cart, recurly_subscription_uuid, recurly_adjustment_uuid, recurly_adjustment_credit_uuid) product_info = shopping_cart.product_info - sale.order_total = sale.order_total + product_info[:total_price] + sale.order_total = sale.order_total + product_info[:real_price] sale_line_item = SaleLineItem.new sale_line_item.product_type = shopping_cart.cart_type @@ -33,7 +38,9 @@ module JamRuby sale_line_item.recurly_plan_code = product_info[:plan_code] sale_line_item.product_id = shopping_cart.cart_id sale_line_item.recurly_subscription_uuid = recurly_subscription_uuid - sale_line_item.sale = sale + sale_line_item.recurly_adjustment_uuid = recurly_adjustment_uuid + sale_line_item.recurly_adjustment_credit_uuid = recurly_adjustment_credit_uuid + sale.sale_line_items << sale_line_item sale_line_item.save sale_line_item end diff --git a/ruby/lib/jam_ruby/models/shopping_cart.rb b/ruby/lib/jam_ruby/models/shopping_cart.rb index b2bbc13ce..ed74d2490 100644 --- a/ruby/lib/jam_ruby/models/shopping_cart.rb +++ b/ruby/lib/jam_ruby/models/shopping_cart.rb @@ -1,8 +1,19 @@ module JamRuby class ShoppingCart < ActiveRecord::Base + # just a normal purchase; used on the description field of a recurly adjustment + PURCHASE_NORMAL = 'purchase-normal' + # a free purchase; used on the description field of a recurly adjustment + PURCHASE_FREE = 'purchase-free' + # a techinicality of Recurly; we create a free-credit adjustment to balance out the free purchase adjustment + PURCHASE_FREE_CREDIT = 'purchase-free-credit' + + PURCHASE_REASONS = [PURCHASE_NORMAL, PURCHASE_FREE, PURCHASE_FREE_CREDIT] + attr_accessible :quantity, :cart_type, :product_info + validates_uniqueness_of :cart_id, scope: :cart_type + belongs_to :user, :inverse_of => :shopping_carts, :class_name => "JamRuby::User", :foreign_key => "user_id" validates :cart_id, presence: true @@ -14,14 +25,20 @@ module JamRuby def product_info product = self.cart_product - {name: product.name, price: product.price, product_id: cart_id, plan_code: product.plan_code, total_price: total_price(product), quantity: quantity, marked_for_redeem: marked_for_redeem} unless product.nil? + {name: product.name, price: product.price, product_id: cart_id, plan_code: product.plan_code, real_price: real_price(product), total_price: total_price(product), quantity: quantity, marked_for_redeem: marked_for_redeem} unless product.nil? + end + + # multiply quantity by price + def total_price(product) + quantity * product.price end # multiply (quantity - redeemable) by price - def total_price(product) + def real_price(product) (quantity - marked_for_redeem) * product.price end + def cart_product self.cart_class_name.classify.constantize.find_by_id(self.cart_id) unless self.cart_class_name.blank? end @@ -51,6 +68,59 @@ module JamRuby cart end + def is_jam_track? + cart_type == JamTrack::PRODUCT_TYPE + end + + + # returns an array of adjustments for the shopping cart + def create_adjustment_attributes(current_user) + raise "not a jam track" unless is_jam_track? + + info = self.product_info + + if free? + + # create the credit, then the pseudo charge + [ + { + accounting_code: PURCHASE_FREE_CREDIT, + currency: 'USD', + unit_amount_in_cents: -(info[:total_price] * 100).to_i, + description: "JamTrack: " + info[:name] + " (Credit)", + tax_exempt: true + }, + { + accounting_code: PURCHASE_FREE, + currency: 'USD', + unit_amount_in_cents: (info[:total_price] * 100).to_i, + description: "JamTrack: " + info[:name], + tax_exempt: true + } + ] + else + [ + { + accounting_code: PURCHASE_NORMAL, + currency: 'USD', + unit_amount_in_cents: (info[:total_price] * 100).to_i, + description: "JamTrack: " + info[:name], + tax_exempt: false + } + ] + end + end + + def self.is_product_purchase?(adjustment) + (adjustment[:accounting_code].include?(PURCHASE_FREE) || adjustment[:accounting_code].include?(PURCHASE_NORMAL)) && !adjustment[:accounting_code].include?(PURCHASE_FREE_CREDIT) + end + + # recurly_adjustment is a Recurly::Adjustment (http://www.rubydoc.info/gems/recurly/Recurly/Adjustment) + # this asks, 'is this a pending adjustment?' AND 'was this adjustment created by the server (vs manually by someone -- we should leave those alone).' + def self.is_server_pending_adjustment?(recurly_adjustment) + recurly_adjustment.state == 'pending' && (recurly_adjustment.accounting_code.include?(PURCHASE_FREE) || recurly_adjustment.accounting_code.include?(PURCHASE_NORMAL) || recurly_adjustment.accounting_code.include?(PURCHASE_FREE_CREDIT)) + end + # if the user has a redeemable jam_track still on their account, then also check if any shopping carts have already been marked. # if no shpping carts have been marked, then mark it redeemable # should be wrapped in a TRANSACTION @@ -73,20 +143,8 @@ module JamRuby def self.add_jam_track_to_cart(any_user, jam_track) cart = nil ShoppingCart.transaction do - # does this user already have this JamTrack in their cart? If so, don't add it. - - duplicate_found = false - any_user.shopping_carts.each do |shopping_cart| - if shopping_cart.cart_type == JamTrack::PRODUCT_TYPE && shopping_cart.cart_id == jam_track.id - duplicate_found = true - return - end - end - - unless duplicate_found - mark_redeem = ShoppingCart.user_has_redeemable_jam_track?(any_user) - cart = ShoppingCart.create(any_user, jam_track, 1, mark_redeem) - end + mark_redeem = ShoppingCart.user_has_redeemable_jam_track?(any_user) + cart = ShoppingCart.create(any_user, jam_track, 1, mark_redeem) end cart end diff --git a/ruby/lib/jam_ruby/recurly_client.rb b/ruby/lib/jam_ruby/recurly_client.rb index ce9f44f9f..1ff419667 100644 --- a/ruby/lib/jam_ruby/recurly_client.rb +++ b/ruby/lib/jam_ruby/recurly_client.rb @@ -1,6 +1,6 @@ require 'recurly' module JamRuby - class RecurlyClient + class RecurlyClient def initialize() @log = Logging.logger[self] end @@ -11,37 +11,37 @@ module JamRuby begin #puts "Recurly.api_key: #{Recurly.api_key}" account = Recurly::Account.create(options) - raise RecurlyClientError.new(account.errors) if account.errors.any? - rescue Recurly::Error, NoMethodError => x + raise RecurlyClientError.new(account.errors) if account.errors.any? + rescue Recurly::Error, NoMethodError => x #puts "Error: #{x} : #{Kernel.caller}" raise RecurlyClientError, x.to_s else if account - current_user.update_attribute(:recurly_code, account.account_code) - end - end - account + current_user.update_attribute(:recurly_code, account.account_code) + end + end + account end def has_account?(current_user) - account = get_account(current_user) + account = get_account(current_user) !!account end def delete_account(current_user) - account = get_account(current_user) - if (account) + account = get_account(current_user) + if (account) begin - account.destroy + account.destroy rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end - else + else raise RecurlyClientError, "Could not find account to delete." end account end - + def get_account(current_user) current_user && current_user.recurly_code ? Recurly::Account.find(current_user.recurly_code) : nil rescue Recurly::Error => x @@ -51,9 +51,9 @@ module JamRuby def update_account(current_user, billing_info=nil) account = get_account(current_user) if(account.present?) - options = account_hash(current_user, billing_info) + options = account_hash(current_user, billing_info) begin - account.update_attributes(options) + account.update_attributes(options) rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end @@ -95,7 +95,7 @@ module JamRuby raise RecurlyClientError, x.to_s end - raise RecurlyClientError.new(account.errors) if account.errors.any? + raise RecurlyClientError.new(account.errors) if account.errors.any? else raise RecurlyClientError, "Could not find account to update billing info." end @@ -121,21 +121,21 @@ module JamRuby #puts "subscription.plan.plan_code: #{subscription.plan.plan_code} / #{jam_track.plan_code} / #{subscription.plan.plan_code == jam_track.plan_code}" if(subscription.plan.plan_code == jam_track.plan_code) subscription.terminate(:full) - raise RecurlyClientError.new(subscription.errors) if subscription.errors.any? + raise RecurlyClientError.new(subscription.errors) if subscription.errors.any? terminated = true end - end + end if terminated - jam_track_right.destroy() + jam_track_right.destroy() else raise RecurlyClientError, "Subscription '#{jam_track.plan_code}' not found for this user; could not issue refund." end - + rescue Recurly::Error, NoMethodError => x raise RecurlyClientError, x.to_s end - + else raise RecurlyClientError, "Could not find account to refund order." end @@ -177,89 +177,18 @@ module JamRuby raise RecurlyClientError.new(plan.errors) if plan.errors.any? end - def place_order(current_user, jam_track, shopping_cart, sale) - jam_track_right = nil - account = get_account(current_user) - if (account.present?) - begin - - # see if we can find existing plan for this plan_code, which should occur for previous-in-time error scenarios - recurly_subscription_uuid = nil - account.subscriptions.find_each do |subscription| - if subscription.plan.plan_code == jam_track.plan_code - recurly_subscription_uuid = subscription.uuid - break - end - end - - free = false - - # this means we already have a subscription, so don't try to create a new one for the same plan (Recurly would fail this anyway) - unless recurly_subscription_uuid - - # if the shopping cart was specified, see if the item should be free - free = shopping_cart.nil? ? false : shopping_cart.free? - # and if it's free, squish the charge to 0. - unit_amount_in_cents = free ? 0 : nil - subscription = Recurly::Subscription.create(:account=>account, :plan_code=>jam_track.plan_code, unit_amount_in_cents: unit_amount_in_cents) - - raise RecurlyClientError.new(subscription.errors) if subscription.errors.any? - - # add a line item for the sale - sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, subscription.uuid) - - unless sale_line_item.valid? - @log.error("sale item invalid! #{sale_line_item.errors.inspect}") - puts("sale item invalid! #{sale_line_item.errors.inspect}") - Stats.write('web.recurly.purchase.sale_invalid', {message: sale.errors.to_s, value:1}) - end - - # delete from shopping cart the subscription - shopping_cart.destroy if shopping_cart - - recurly_subscription_uuid = subscription.uuid - end - - #raise RecurlyClientError, "Plan code '#{paid_subscription.plan_code}' doesn't match jam track: '#{jam_track.plan_code}'" unless recurly_subscription_uuid - - jam_track_right = JamRuby::JamTrackRight.find_or_create_by_user_id_and_jam_track_id(current_user.id, jam_track.id) do |jam_track_right| - jam_track_right.redeemed = free - end - - # also if the purchase was a free one, then update the user record to no longer allow redeemed jamtracks - User.where(id: current_user.id).update_all(has_redeemable_jamtrack: false) if free - - # this can't go in the block above, as it's here to fix bad subscription UUIDs in an update path - if jam_track_right.recurly_subscription_uuid != recurly_subscription_uuid - jam_track_right.recurly_subscription_uuid = recurly_subscription_uuid - jam_track_right.save - end - - raise RecurlyClientError.new("Error creating jam_track_right for jam_track: #{jam_track.id}") if jam_track_right.nil? - raise RecurlyClientError.new(jam_track_right.errors) if jam_track_right.errors.any? - rescue Recurly::Error, NoMethodError => x - raise RecurlyClientError, x.to_s - end - - raise RecurlyClientError.new(account.errors) if account.errors.any? - else - raise RecurlyClientError, "Could not find account to place order." - end - jam_track_right - end def find_or_create_account(current_user, billing_info) account = get_account(current_user) - + if(account.nil?) account = create_account(current_user, billing_info) else update_billing_info(current_user, billing_info) - end - account + end + account end - - + private def account_hash(current_user, billing_info) options = { @@ -273,7 +202,7 @@ module JamRuby country: current_user.country } } - + options[:billing_info] = billing_info if billing_info options end @@ -282,11 +211,11 @@ module JamRuby class RecurlyClientError < Exception attr_accessor :errors def initialize(data) - if data.respond_to?('has_key?') - self.errors = data + if data.respond_to?('has_key?') + self.errors = data else self.errors = {:message=>data.to_s} - end + end end # initialize def to_s diff --git a/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb b/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb index 3f69ce5eb..ba89aee20 100644 --- a/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb +++ b/ruby/spec/jam_ruby/models/recurly_transaction_web_hook_spec.rb @@ -9,6 +9,7 @@ require 'spec_helper' describe RecurlyTransactionWebHook do + let(:refund_xml) {' @@ -120,8 +121,15 @@ describe RecurlyTransactionWebHook do it "deletes jam_track_right when refunded" do + sale = Sale.create_jam_track_sale(@user) + sale.recurly_invoice_id = '2da71ad9c657adf9fe618e4f058c78bb' + sale.recurly_total_in_cents = 216 + sale.save! # create a jam_track right, which should be whacked as soon as we craete the web hook - jam_track_right = FactoryGirl.create(:jam_track_right, user: @user, recurly_subscription_uuid: '2da71ad97c826a7b784c264ac59c04de') + jam_track_right = FactoryGirl.create(:jam_track_right, user: @user, recurly_adjustment_uuid: 'bleh') + + shopping_cart = ShoppingCart.create(@user, jam_track_right.jam_track) + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, '2da71ad9c657adf9fe618e4f058c78bb', nil) document = Nokogiri::XML(refund_xml) @@ -131,8 +139,16 @@ describe RecurlyTransactionWebHook do end it "deletes jam_track_right when voided" do + + sale = Sale.create_jam_track_sale(@user) + sale.recurly_invoice_id = '2da71ad9c657adf9fe618e4f058c78bb' + sale.recurly_total_in_cents = 216 + sale.save! # create a jam_track right, which should be whacked as soon as we craete the web hook - jam_track_right = FactoryGirl.create(:jam_track_right, user: @user, recurly_subscription_uuid: '2da71ad97c826a7b784c264ac59c04de') + jam_track_right = FactoryGirl.create(:jam_track_right, user: @user, recurly_adjustment_uuid: 'blah') + + shopping_cart = ShoppingCart.create(@user, jam_track_right.jam_track) + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, '2da71ad9c657adf9fe618e4f058c78bb', nil) document = Nokogiri::XML(void_xml) diff --git a/ruby/spec/jam_ruby/models/sale_spec.rb b/ruby/spec/jam_ruby/models/sale_spec.rb index 0c9b089b1..91d298b32 100644 --- a/ruby/spec/jam_ruby/models/sale_spec.rb +++ b/ruby/spec/jam_ruby/models/sale_spec.rb @@ -1,72 +1,289 @@ - require 'spec_helper' describe Sale do - describe "check_integrity" do + + describe "place_order" do let(:user) {FactoryGirl.create(:user)} - let(:jam_track) {FactoryGirl.create(:jam_track)} + let(:jamtrack) { FactoryGirl.create(:jam_track) } + let(:jam_track_price_in_cents) { (jamtrack.price * 100).to_i } + let(:client) { RecurlyClient.new } + let(:billing_info) { + info = {} + info[:first_name] = user.first_name + info[:last_name] = user.last_name + info[:address1] = 'Test Address 1' + info[:address2] = 'Test Address 2' + info[:city] = user.city + info[:state] = user.state + info[:country] = user.country + info[:zip] = '12345' + info[:number] = '4111-1111-1111-1111' + info[:month] = '08' + info[:year] = '2017' + info[:verification_value] = '111' + info + } + + after(:each) do + if user.recurly_code + account = Recurly::Account.find(user.recurly_code) + if account.present? + account.destroy + end + end + end + + + it "for a free jam track" do + shopping_cart = ShoppingCart.create user, jamtrack, 1, true + client.find_or_create_account(user, billing_info) + + sales = Sale.place_order(user, [shopping_cart]) + + user.reload + user.sales.length.should eq(1) + + sales.should eq(user.sales) + sale = sales[0] + sale.recurly_invoice_id.should_not be_nil + + sale.recurly_subtotal_in_cents.should eq(jam_track_price_in_cents) + sale.recurly_tax_in_cents.should eq(0) + sale.recurly_total_in_cents.should eq(0) + sale.recurly_currency.should eq('USD') + sale.order_total.should eq(0) + sale.sale_line_items.length.should == 1 + sale_line_item = sale.sale_line_items[0] + sale_line_item.recurly_tax_in_cents.should eq(0) + sale_line_item.recurly_total_in_cents.should eq(jam_track_price_in_cents) + sale_line_item.recurly_currency.should eq('USD') + sale_line_item.recurly_discount_in_cents.should eq(0) + sale_line_item.product_type.should eq(JamTrack::PRODUCT_TYPE) + sale_line_item.unit_price.should eq(jamtrack.price) + sale_line_item.quantity.should eq(1) + sale_line_item.free.should eq(1) + sale_line_item.sales_tax.should be_nil + sale_line_item.shipping_handling.should eq(0) + sale_line_item.recurly_plan_code.should eq(jamtrack.plan_code) + sale_line_item.product_id.should eq(jamtrack.id) + sale_line_item.recurly_subscription_uuid.should be_nil + sale_line_item.recurly_adjustment_uuid.should_not be_nil + sale_line_item.recurly_adjustment_credit_uuid.should_not be_nil + sale_line_item.recurly_adjustment_uuid.should eq(user.jam_track_rights.last.recurly_adjustment_uuid) + sale_line_item.recurly_adjustment_credit_uuid.should eq(user.jam_track_rights.last.recurly_adjustment_credit_uuid) + + # verify subscription is in Recurly + recurly_account = client.get_account(user) + adjustments = recurly_account.adjustments + adjustments.should_not be_nil + adjustments.should have(2).items + free_purchase= adjustments[0] + free_purchase.unit_amount_in_cents.should eq((jamtrack.price * 100).to_i) + free_purchase.accounting_code.should eq(ShoppingCart::PURCHASE_FREE) + free_purchase.description.should eq("JamTrack: " + jamtrack.name) + free_purchase.state.should eq('invoiced') + free_purchase.uuid.should eq(sale_line_item.recurly_adjustment_uuid) + + free_credit = adjustments[1] + free_credit.unit_amount_in_cents.should eq(-(jamtrack.price * 100).to_i) + free_credit.accounting_code.should eq(ShoppingCart::PURCHASE_FREE_CREDIT) + free_credit.description.should eq("JamTrack: " + jamtrack.name + " (Credit)") + free_credit.state.should eq('invoiced') + free_credit.uuid.should eq(sale_line_item.recurly_adjustment_credit_uuid) + + invoices = recurly_account.invoices + invoices.should have(1).items + invoice = invoices[0] + invoice.uuid.should eq(sale.recurly_invoice_id) + invoice.line_items.should have(2).items # should have both adjustments associated + invoice.line_items[0].should eq(free_credit) + invoice.line_items[1].should eq(free_purchase) + invoice.subtotal_in_cents.should eq((jamtrack.price * 100).to_i) + invoice.total_in_cents.should eq(0) + invoice.state.should eq('collected') + + # verify jam_track_rights data + user.jam_track_rights.should_not be_nil + user.jam_track_rights.should have(1).items + user.jam_track_rights.last.jam_track.id.should eq(jamtrack.id) + user.jam_track_rights.last.redeemed.should be_true + user.has_redeemable_jamtrack.should be_false + end + + it "for a normally priced jam track" do + user.has_redeemable_jamtrack = false + user.save! + shopping_cart = ShoppingCart.create user, jamtrack, 1, false + client.find_or_create_account(user, billing_info) + + sales = Sale.place_order(user, [shopping_cart]) + + user.reload + user.sales.length.should eq(1) + + sales.should eq(user.sales) + sale = sales[0] + sale.recurly_invoice_id.should_not be_nil + + sale.recurly_subtotal_in_cents.should eq(jam_track_price_in_cents) + sale.recurly_tax_in_cents.should eq(0) + sale.recurly_total_in_cents.should eq(jam_track_price_in_cents) + sale.recurly_currency.should eq('USD') + + sale.order_total.should eq(jamtrack.price) + sale.sale_line_items.length.should == 1 + sale_line_item = sale.sale_line_items[0] + # validate we are storing pricing info from recurly + sale_line_item.recurly_tax_in_cents.should eq(0) + sale_line_item.recurly_total_in_cents.should eq(jam_track_price_in_cents) + sale_line_item.recurly_currency.should eq('USD') + sale_line_item.recurly_discount_in_cents.should eq(0) + sale_line_item.product_type.should eq(JamTrack::PRODUCT_TYPE) + sale_line_item.unit_price.should eq(jamtrack.price) + sale_line_item.quantity.should eq(1) + sale_line_item.free.should eq(0) + sale_line_item.sales_tax.should be_nil + sale_line_item.shipping_handling.should eq(0) + sale_line_item.recurly_plan_code.should eq(jamtrack.plan_code) + sale_line_item.product_id.should eq(jamtrack.id) + sale_line_item.recurly_subscription_uuid.should be_nil + sale_line_item.recurly_adjustment_uuid.should_not be_nil + sale_line_item.recurly_adjustment_credit_uuid.should be_nil + sale_line_item.recurly_adjustment_uuid.should eq(user.jam_track_rights.last.recurly_adjustment_uuid) + + # verify subscription is in Recurly + recurly_account = client.get_account(user) + adjustments = recurly_account.adjustments + adjustments.should_not be_nil + adjustments.should have(1).items + purchase= adjustments[0] + purchase.unit_amount_in_cents.should eq((jamtrack.price * 100).to_i) + purchase.accounting_code.should eq(ShoppingCart::PURCHASE_NORMAL) + purchase.description.should eq("JamTrack: " + jamtrack.name) + purchase.state.should eq('invoiced') + purchase.uuid.should eq(sale_line_item.recurly_adjustment_uuid) + + invoices = recurly_account.invoices + invoices.should have(1).items + invoice = invoices[0] + invoice.uuid.should eq(sale.recurly_invoice_id) + invoice.line_items.should have(1).items # should have single adjustment associated + invoice.line_items[0].should eq(purchase) + invoice.subtotal_in_cents.should eq((jamtrack.price * 100).to_i) + invoice.total_in_cents.should eq((jamtrack.price * 100).to_i) + invoice.state.should eq('collected') + + # verify jam_track_rights data + user.jam_track_rights.should_not be_nil + user.jam_track_rights.should have(1).items + user.jam_track_rights.last.jam_track.id.should eq(jamtrack.id) + user.jam_track_rights.last.redeemed.should be_false + user.has_redeemable_jamtrack.should be_false + end + + it "for a jamtrack already owned" do + shopping_cart = ShoppingCart.create user, jamtrack, 1, true + client.find_or_create_account(user, billing_info) + + sales = Sale.place_order(user, [shopping_cart]) + + user.reload + user.sales.length.should eq(1) + + shopping_cart = ShoppingCart.create user, jamtrack, 1, false + sales = Sale.place_order(user, [shopping_cart]) + sales.should have(0).items + # also, verify that no earlier adjustments were affected + recurly_account = client.get_account(user) + adjustments = recurly_account.adjustments + adjustments.should have(2).items + end + + # this test counts on the fact that two adjustments are made when buying a free JamTrack + # so if we make the second adjustment invalid from Recurly's standpoint, then + # we can see if the first one is ultimately destroyed + it "rolls back created adjustments if error" do + + shopping_cart = ShoppingCart.create user, jamtrack, 1, true + + # grab the real response; we will modify it to make a nil accounting code + adjustment_attrs = shopping_cart.create_adjustment_attributes(user) + client.find_or_create_account(user, billing_info) + + adjustment_attrs[1][:unit_amount_in_cents] = nil # invalid amount + ShoppingCart.any_instance.stub(:create_adjustment_attributes).and_return(adjustment_attrs) + + expect { Sale.place_order(user, [shopping_cart]) }.to raise_error(JamRuby::RecurlyClientError) + + user.reload + user.sales.should have(0).items + + recurly_account = client.get_account(user) + recurly_account.adjustments.should have(0).items + end + + it "rolls back adjustments created before the order" do + shopping_cart = ShoppingCart.create user, jamtrack, 1, true + client.find_or_create_account(user, billing_info) + + # create a single adjustment on the account + adjustment_attrs = shopping_cart.create_adjustment_attributes(user) + recurly_account = client.get_account(user) + adjustment = recurly_account.adjustments.new (adjustment_attrs[0]) + adjustment.save + adjustment.errors.any?.should be_false + + sales = Sale.place_order(user, [shopping_cart]) + + user.reload + + recurly_account = client.get_account(user) + adjustments = recurly_account.adjustments + adjustments.should have(2).items # two adjustments are created for a free jamtrack; that should be all there is + end + end + + describe "check_integrity_of_jam_track_sales" do + + let(:user) { FactoryGirl.create(:user) } + let(:jam_track) { FactoryGirl.create(:jam_track) } it "empty" do - check_integrity = Sale.check_integrity + check_integrity = Sale.check_integrity_of_jam_track_sales check_integrity.length.should eq(1) r = check_integrity[0] r.total.to_i.should eq(0) - r.not_known.to_i.should eq(0) - r.succeeded.to_i.should eq(0) - r.failed.to_i.should eq(0) - r.refunded.to_i.should eq(0) - r.voided.to_i.should eq(0) - end - - it "one unknown sale" do - sale = Sale.create(user) - shopping_cart = ShoppingCart.create(user, jam_track) - SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid') - - check_integrity = Sale.check_integrity - r = check_integrity[0] - r.total.to_i.should eq(1) - r.not_known.to_i.should eq(1) - r.succeeded.to_i.should eq(0) - r.failed.to_i.should eq(0) - r.refunded.to_i.should eq(0) r.voided.to_i.should eq(0) end it "one succeeded sale" do - sale = Sale.create(user) + sale = Sale.create_jam_track_sale(user) shopping_cart = ShoppingCart.create(user, jam_track) - SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid') - FactoryGirl.create(:recurly_transaction_web_hook, subscription_id: 'some_recurly_uuid') + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_recurly_invoice_id', nil) - - check_integrity = Sale.check_integrity + check_integrity = Sale.check_integrity_of_jam_track_sales r = check_integrity[0] r.total.to_i.should eq(1) - r.not_known.to_i.should eq(0) - r.succeeded.to_i.should eq(1) - r.failed.to_i.should eq(0) - r.refunded.to_i.should eq(0) r.voided.to_i.should eq(0) end - it "one failed sale" do - sale = Sale.create(user) - shopping_cart = ShoppingCart.create(user, jam_track) - SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid') - FactoryGirl.create(:recurly_transaction_web_hook_failed, subscription_id: 'some_recurly_uuid') - check_integrity = Sale.check_integrity + it "one voided sale" do + sale = Sale.create_jam_track_sale(user) + sale.recurly_invoice_id = 'some_recurly_invoice_id' + sale.save! + shopping_cart = ShoppingCart.create(user, jam_track) + SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_recurly_invoice_id', nil) + FactoryGirl.create(:recurly_transaction_web_hook, transaction_type: RecurlyTransactionWebHook::VOID, invoice_id: 'some_recurly_invoice_id') + + check_integrity = Sale.check_integrity_of_jam_track_sales r = check_integrity[0] r.total.to_i.should eq(1) - r.not_known.to_i.should eq(0) - r.succeeded.to_i.should eq(0) - r.failed.to_i.should eq(1) - r.refunded.to_i.should eq(0) - r.voided.to_i.should eq(0) + r.voided.to_i.should eq(1) end + end end diff --git a/ruby/spec/jam_ruby/recurly_client_spec.rb b/ruby/spec/jam_ruby/recurly_client_spec.rb index cf51c4b8c..108fe1600 100644 --- a/ruby/spec/jam_ruby/recurly_client_spec.rb +++ b/ruby/spec/jam_ruby/recurly_client_spec.rb @@ -86,42 +86,7 @@ describe RecurlyClient do found.state.should eq('closed') end - it "can place order" do - sale = Sale.create(@user) - sale = Sale.find(sale.id) - shopping_cart = ShoppingCart.create @user, @jamtrack, 1, true - history_items = @client.payment_history(@user).length - @client.find_or_create_account(@user, @billing_info) - expect{@client.place_order(@user, @jamtrack, shopping_cart, sale)}.not_to raise_error() - - # verify jam_track_rights data - @user.jam_track_rights.should_not be_nil - @user.jam_track_rights.should have(1).items - @user.jam_track_rights.last.jam_track.id.should eq(@jamtrack.id) - - # verify sales data - sale = Sale.find(sale.id) - sale.sale_line_items.length.should == 1 - sale_line_item = sale.sale_line_items[0] - sale_line_item.product_type.should eq(JamTrack::PRODUCT_TYPE) - sale_line_item.unit_price.should eq(@jamtrack.price) - sale_line_item.quantity.should eq(1) - sale_line_item.free.should eq(1) - sale_line_item.sales_tax.should be_nil - sale_line_item.shipping_handling.should eq(0) - sale_line_item.recurly_plan_code.should eq(@jamtrack.plan_code) - sale_line_item.product_id.should eq(@jamtrack.id) - sale_line_item.recurly_subscription_uuid.should_not be_nil - sale_line_item.recurly_subscription_uuid.should eq(@user.jam_track_rights.last.recurly_subscription_uuid) - - # verify subscription is in Recurly - subs = @client.get_account(@user).subscriptions - subs.should_not be_nil - subs.should have(1).items - - @client.payment_history(@user).should have(history_items+1).items - end - +=begin it "can refund subscription" do sale = Sale.create(@user) shopping_cart = ShoppingCart.create @user, @jamtrack, 1 @@ -141,18 +106,7 @@ describe RecurlyClient do @jamtrack.reload @jamtrack.jam_track_rights.should have(0).items end +=end - it "detects error on double order" do - sale = Sale.create(@user) - shopping_cart = ShoppingCart.create @user, @jamtrack, 1 - @client.find_or_create_account(@user, @billing_info) - jam_track_right = @client.place_order(@user, @jamtrack, shopping_cart, sale) - jam_track_right.recurly_subscription_uuid.should_not be_nil - - shopping_cart = ShoppingCart.create @user, @jamtrack, 1 - jam_track_right2 = @client.place_order(@user, @jamtrack, shopping_cart, sale) - jam_track_right.should eq(jam_track_right2) - jam_track_right.recurly_subscription_uuid.should eq(jam_track_right.recurly_subscription_uuid) - end end diff --git a/ruby/spec/support/utilities.rb b/ruby/spec/support/utilities.rb index 94294b86c..5bed19487 100644 --- a/ruby/spec/support/utilities.rb +++ b/ruby/spec/support/utilities.rb @@ -3,6 +3,14 @@ JAMKAZAM_TESTING_BUCKET = 'jamkazam-testing' # cuz i'm not comfortable using aws def app_config klass = Class.new do + def email_alerts_alias + 'alerts@jamkazam.com' + end + + def email_generic_from + 'nobody@jamkazam.com' + end + def aws_bucket JAMKAZAM_TESTING_BUCKET end diff --git a/web/app/assets/javascripts/checkout_order.js b/web/app/assets/javascripts/checkout_order.js index 9024f5745..bbf95b98b 100644 --- a/web/app/assets/javascripts/checkout_order.js +++ b/web/app/assets/javascripts/checkout_order.js @@ -99,7 +99,7 @@ var sub_total = 0.0 var taxes = 0.0 $.each(carts, function(index, cart) { - sub_total += parseFloat(cart.product_info.total_price) + sub_total += parseFloat(cart.product_info.real_price) }); if(carts.length == 0) { data.grand_total = '-.--' @@ -147,68 +147,45 @@ var planPricing = {} + + var priceElement = $screen.find('.order-right-page .plan.jamtrack') + + if(priceElement.length == 0) { + logger.error("unable to find price element for jamtrack"); + app.notify({title: "Error Encountered", text: "Unable to find plan info for jam track"}) + return false; + } + + logger.debug("creating recurly pricing element for plan: " + gon.recurly_tax_estimate_jam_track_plan) + + var effectiveQuantity = 0 + context._.each(carts, function(cart) { - var priceElement = $screen.find('.order-right-page .plan[data-plan-code="' + cart.product_info.plan_code +'"]') - - if(priceElement.length == 0) { - logger.error("unable to find price element for " + cart.product_info.plan_code, cart); - app.notify({title: "Error Encountered", text: "Unable to find plan info for " + cart.product_info.plan_code}) - return false; - } - - logger.debug("creating recurly pricing element for plan: " + cart.product_info.plan_code) - var pricing = context.recurly.Pricing(); - pricing.plan_code = cart.product_info.plan_code; - pricing.resolved = false; - pricing.effective_quantity = cart.product_info.quantity - cart.product_info.marked_for_redeem - planPricing[pricing.plan_code] = pricing; - - // this is called when the plan is resolved against Recurly. It will have tax info, which is the only way we can get it. - pricing.on('change', function(price) { - - var resolvedPrice = planPricing[this.plan_code]; - if(!resolvedPrice) { - logger.error("unable to find price info in storage") - app.notify({title: "Error Encountered", text: "Unable to find plan info in storage"}) - return; - } - else { - logger.debug("pricing resolved for plan: " + this.plan_code) - } - resolvedPrice.resolved = true; - - var allResolved = true; - var totalTax = 0; - var totalPrice = 0; - - // let's see if all plans have been resolved via API; and add up total price and taxes for display - $.each(planPricing, function(plan_code, priceObject) { - logger.debug("resolved recurly priceObject", priceObject) - - if(!priceObject.resolved) { - allResolved = false; - return false; - } - else { - var unitTax = Number(priceObject.price.now.tax) * priceObject.effective_quantity; - totalTax += unitTax; - - var totalUnitPrice = Number(priceObject.price.now.total) * priceObject.effective_quantity; - totalPrice += totalUnitPrice; - } - }) - - if(allResolved) { - $screen.find('.order-right-page .order-items-value.taxes').text('$' + totalTax.toFixed(2)) - $screen.find('.order-right-page .order-items-value.grand-total').text('$' + totalPrice.toFixed(2)) - } - else - { - logger.debug("still waiting on more plans to resolve") - } - }) - pricing.attach(priceElement.eq(0)) + effectiveQuantity += cart.product_info.quantity - cart.product_info.marked_for_redeem }) + + var pricing = context.recurly.Pricing(); + pricing.plan_code = gon.recurly_tax_estimate_jam_track_plan; + pricing.resolved = false; + pricing.effective_quantity = 1 + + // this is called when the plan is resolved against Recurly. It will have tax info, which is the only way we can get it. + pricing.on('change', function(price) { + + var totalTax = 0; + var totalPrice = 0; + + var unitTax = Number(pricing.price.now.tax) * effectiveQuantity; + totalTax += unitTax; + + var totalUnitPrice = Number(pricing.price.now.total) * effectiveQuantity; + totalPrice += totalUnitPrice; + + $screen.find('.order-right-page .order-items-value.taxes').text('$' + totalTax.toFixed(2)) + $screen.find('.order-right-page .order-items-value.grand-total').text('$' + totalPrice.toFixed(2)) + }) + + pricing.attach(priceElement.eq(0)) } } diff --git a/web/app/controllers/api_recurly_controller.rb b/web/app/controllers/api_recurly_controller.rb index 44b50c48a..d69b7b3cf 100644 --- a/web/app/controllers/api_recurly_controller.rb +++ b/web/app/controllers/api_recurly_controller.rb @@ -18,27 +18,27 @@ class ApiRecurlyController < ApiController if current_user - # keep reuse card up-to-date - User.where(id: current_user.id).update_all(reuse_card: params[:reuse_card_next_time]) + # keep reuse card up-to-date + User.where(id: current_user.id).update_all(reuse_card: params[:reuse_card_next_time]) else options = { - remote_ip: request.remote_ip, - first_name: billing_info[:first_name], - last_name: billing_info[:last_name], - email: params[:email], - password: params[:password], - password_confirmation: params[:password], - terms_of_service: terms_of_service, - instruments: [{ :instrument_id => 'other', :proficiency_level => 1, :priority => 1 }], - birth_date: nil, - location: { :country => billing_info[:country], :state => billing_info[:state], :city => billing_info[:city]}, - musician: true, - skip_recaptcha: true, - invited_user: nil, - fb_signup: nil, - signup_confirm_url: ApplicationHelper.base_uri(request) + "/confirm", - any_user: any_user, - reuse_card: reuse_card_next_time + remote_ip: request.remote_ip, + first_name: billing_info[:first_name], + last_name: billing_info[:last_name], + email: params[:email], + password: params[:password], + password_confirmation: params[:password], + terms_of_service: terms_of_service, + instruments: [{:instrument_id => 'other', :proficiency_level => 1, :priority => 1}], + birth_date: nil, + location: {:country => billing_info[:country], :state => billing_info[:state], :city => billing_info[:city]}, + musician: true, + skip_recaptcha: true, + invited_user: nil, + fb_signup: nil, + signup_confirm_url: ApplicationHelper.base_uri(request) + "/confirm", + any_user: any_user, + reuse_card: reuse_card_next_time } user = UserManager.new.signup(options) @@ -61,9 +61,9 @@ class ApiRecurlyController < ApiController @account = @client.find_or_create_account(current_user, billing_info) end - render :json=>account_json(@account) + render :json => account_json(@account) rescue RecurlyClientError => x - render json: { :message => x.inspect, errors: x.errors }, :status => 404 + render json: {:message => x.inspect, errors: x.errors}, :status => 404 end end @@ -71,86 +71,80 @@ class ApiRecurlyController < ApiController @client.delete_account(current_user) render json: {}, status: 200 rescue RecurlyClientError => x - render json: { :message => x.inspect, errors: x.errors}, :status => 404 + render json: {:message => x.inspect, errors: x.errors}, :status => 404 end # get Recurly account def get_account @account = @client.get_account(current_user) - render :json=>account_json(@account) + render :json => account_json(@account) rescue RecurlyClientError => e - render json: { message: x.inspect, errors: x.errors}, :status => 404 + render json: {message: x.inspect, errors: x.errors}, :status => 404 end # get Recurly payment history def payment_history @payments=@client.payment_history(current_user) - render :json=>{payments: @payments} + render :json => {payments: @payments} rescue RecurlyClientError => x - render json: { message: x.inspect, errors: x.errors}, :status => 404 + render json: {message: x.inspect, errors: x.errors}, :status => 404 end # update Recurly account def update_account - @account=@client.update_account(current_user, params[:billing_info]) - render :json=>account_json(@account) - rescue RecurlyClientError => x - render json: { message: x.inspect, errors: x.errors}, :status => 404 + @account=@client.update_account(current_user, params[:billing_info]) + render :json => account_json(@account) + rescue RecurlyClientError => x + render json: {message: x.inspect, errors: x.errors}, :status => 404 end # get Billing Information def billing_info @account = @client.get_account(current_user) if @account - render :json=> account_json(@account) + render :json => account_json(@account) else - render :json=> {}, :status => 404 + render :json => {}, :status => 404 end rescue RecurlyClientError => x - render json: { message: x.inspect, errors: x.errors}, :status => 404 + render json: {message: x.inspect, errors: x.errors}, :status => 404 end # update Billing Information def update_billing_info - @account=@client.update_billing_info(current_user, params[:billing_info]) - render :json=> account_json(@account) + @account = @client.update_billing_info(current_user, params[:billing_info]) + render :json => account_json(@account) rescue RecurlyClientError => x - render json: { message: x.inspect, errors: x.errors}, :status => 404 + render json: {message: x.inspect, errors: x.errors}, :status => 404 end def place_order error=nil - response = {jam_tracks:[]} + response = {jam_tracks: []} - sale = Sale.create(current_user) + sales = Sale.place_order(current_user, current_user.shopping_carts) - if sale.valid? - current_user.shopping_carts.each do |shopping_cart| - jam_track = shopping_cart.cart_product - - # if shopping_cart has any marked_for_redeem, then we zero out the price by passing in 'free' - # NOTE: shopping_carts have the idea of quantity, but you should only be able to buy at most one JamTrack. So anything > 0 is considered free for a JamTrack - - jam_track_right = @client.place_order(current_user, jam_track, shopping_cart, sale) - # build up the response object with JamTracks that were purchased. - # if this gets more complicated, we should switch to RABL - response[:jam_tracks] << {name: jam_track.name, id: jam_track.id, jam_track_right_id: jam_track_right.id, version: jam_track.version} + sales.each do |sale| + if sale.is_jam_track_sale? + sale.sale_line_items.each do |line_item| + jam_track = line_item.product + jam_track_right = jam_track.right_for_user(current_user) + response[:jam_tracks] << {name: jam_track.name, id: jam_track.id, jam_track_right_id: jam_track_right.id, version: jam_track.version} + end end - else - error = 'can not create sale' end if error - render json: { errors: {message:error}}, :status => 404 + render json: {errors: {message: error}}, :status => 404 else - render :json=>response, :status=>200 + render :json => response, :status => 200 end rescue RecurlyClientError => x - render json: { message: x.inspect, errors: x.errors}, :status => 404 + render json: {message: x.inspect, errors: x.errors}, :status => 404 end -private + private def create_client @client = RecurlyClient.new end @@ -173,5 +167,5 @@ private billing_info: billing_info } end - + end # class \ No newline at end of file diff --git a/web/app/controllers/clients_controller.rb b/web/app/controllers/clients_controller.rb index 928715adf..51813a8d3 100644 --- a/web/app/controllers/clients_controller.rb +++ b/web/app/controllers/clients_controller.rb @@ -16,6 +16,7 @@ class ClientsController < ApplicationController return end + gon.recurly_tax_estimate_jam_track_plan = Rails.application.config.recurly_tax_estimate_jam_track_plan render :layout => 'client' end diff --git a/web/app/views/clients/_checkout_order.html.slim b/web/app/views/clients/_checkout_order.html.slim index 76db4d7ff..ed3578d68 100644 --- a/web/app/views/clients/_checkout_order.html.slim +++ b/web/app/views/clients/_checkout_order.html.slim @@ -115,10 +115,8 @@ script type='text/template' id='template-order-content' .order-right-page h2 PLACE ORDER .recurly-data.hidden - = "{% _.each(data.carts, function(cart) { %}" - .plan data-plan-code="{{cart.product_info.plan_code}}" - input data-recurly="plan" type="text" value="{{cart.product_info.plan_code}}" - = "{% }); %}" + .plan.jamtrack data-plan-code="{{gon.recurly_tax_estimate_jam_track_plan}}" + input data-recurly="plan" type="text" value="{{gon.recurly_tax_estimate_jam_track_plan}}" .order-summary .place-order-center a.button-orange.place-order href="#" PLACE YOUR ORDER diff --git a/web/config/application.rb b/web/config/application.rb index 33ec5248a..173220b55 100644 --- a/web/config/application.rb +++ b/web/config/application.rb @@ -220,7 +220,7 @@ if defined?(Bundler) # amount of time before we think the queue is stuck config.signing_job_queue_max_time = 20 # 20 seconds - config.email_alerts_alias = 'nobody@jamkazam.com' # should be used for 'oh no' server down/service down sorts of emails + config.email_alerts_alias = 'alerts@jamkazam.com' # should be used for 'oh no' server down/service down sorts of emails config.email_generic_from = 'nobody@jamkazam.com' config.email_smtp_address = 'smtp.sendgrid.net' config.email_smtp_port = 587 @@ -323,5 +323,6 @@ if defined?(Bundler) config.one_free_jamtrack_per_user = true config.nominated_jam_track = 'jamtrack-pearljam-alive' + config.recurly_tax_estimate_jam_track_plan = 'jamtrack-acdc-backinblack' end end diff --git a/web/spec/features/checkout_spec.rb b/web/spec/features/checkout_spec.rb index 89b37a366..9484ceed7 100644 --- a/web/spec/features/checkout_spec.rb +++ b/web/spec/features/checkout_spec.rb @@ -40,8 +40,6 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d # make sure plans are there @recurlyClient.create_jam_track_plan(@jamtrack_acdc_backinblack) unless @recurlyClient.find_jam_track_plan(@jamtrack_acdc_backinblack) - @recurlyClient.create_jam_track_plan(@jamtrack_pearljam_evenflow) unless @recurlyClient.find_jam_track_plan(@jamtrack_pearljam_evenflow) - end @@ -552,7 +550,7 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d sale = user.sales.first sale.sale_line_items.length.should eq(2) - acdc_sale = SaleLineItem.find_by_recurly_subscription_uuid(acdc.recurly_subscription_uuid) + acdc_sale = SaleLineItem.find_by_recurly_adjustment_uuid(acdc.recurly_adjustment_uuid) acdc_sale.recurly_plan_code.should eq(jamtrack_acdc_backinblack.plan_code) acdc_sale.product_type.should eq('JamTrack') acdc_sale.product_id.should eq(jamtrack_acdc_backinblack.id) @@ -560,7 +558,7 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d acdc_sale.free.should eq(0) acdc_sale.unit_price.should eq(1.99) acdc_sale.sale.should eq(sale) - pearljam_sale = SaleLineItem.find_by_recurly_subscription_uuid(pearljam.recurly_subscription_uuid) + pearljam_sale = SaleLineItem.find_by_recurly_adjustment_uuid(pearljam.recurly_adjustment_uuid) pearljam_sale.recurly_plan_code.should eq(jamtrack_pearljam_evenflow.plan_code) pearljam_sale.product_type.should eq('JamTrack') pearljam_sale.product_id.should eq(jamtrack_pearljam_evenflow.id) @@ -660,7 +658,7 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d guy.sales.length.should eq(1) sale = guy.sales.first sale.sale_line_items.length.should eq(1) - acdc_sale = SaleLineItem.find_by_recurly_subscription_uuid(jam_track_right.recurly_subscription_uuid) + acdc_sale = SaleLineItem.find_by_recurly_adjustment_uuid(jam_track_right.recurly_adjustment_uuid) acdc_sale.recurly_plan_code.should eq(jamtrack_acdc_backinblack.plan_code) acdc_sale.product_type.should eq('JamTrack') acdc_sale.product_id.should eq(jamtrack_acdc_backinblack.id) @@ -710,7 +708,7 @@ describe "Checkout", :js => true, :type => :feature, :capybara_feature => true d guy.sales.length.should eq(2) sale = guy.sales.last sale.sale_line_items.length.should eq(1) - acdc_sale = SaleLineItem.find_by_recurly_subscription_uuid(jam_track_right.recurly_subscription_uuid) + acdc_sale = SaleLineItem.find_by_recurly_adjustment_uuid(jam_track_right.recurly_adjustment_uuid) acdc_sale.recurly_plan_code.should eq(jamtrack_pearljam_evenflow.plan_code) acdc_sale.product_type.should eq('JamTrack') acdc_sale.product_id.should eq(jamtrack_pearljam_evenflow.id) diff --git a/web/spec/features/individual_jamtrack_band_spec.rb b/web/spec/features/individual_jamtrack_band_spec.rb index 5db9ac971..b72e10982 100644 --- a/web/spec/features/individual_jamtrack_band_spec.rb +++ b/web/spec/features/individual_jamtrack_band_spec.rb @@ -44,7 +44,7 @@ describe "Individual JamTrack Band", :js => true, :type => :feature, :capybara_f @jamtrack_acdc_backinblack = FactoryGirl.create(:jam_track, name: 'Back in Black', original_artist: 'AC/DC', sales_region: 'United States', make_track: true, plan_code: 'jamtrack-acdc-backinblack') - # make sure plans are there + # make sure tax estimate plans are there @recurlyClient.create_jam_track_plan(@jamtrack_acdc_backinblack) unless @recurlyClient.find_jam_track_plan(@jamtrack_acdc_backinblack) end diff --git a/web/spec/spec_helper.rb b/web/spec/spec_helper.rb index fdb947180..20ca30779 100644 --- a/web/spec/spec_helper.rb +++ b/web/spec/spec_helper.rb @@ -187,6 +187,9 @@ bputs "before register capybara" config.include Requests::JsonHelpers, type: :request config.include Requests::FeatureHelpers, type: :feature + # Use the specified formatter + #config.formatter = :documentation + config.before(:suite) do tests_started = true end From d8b582be6418d49bb0414797e96470c3fc345e25 Mon Sep 17 00:00:00 2001 From: Jonathan Kolyer Date: Sun, 12 Apr 2015 06:18:31 +0000 Subject: [PATCH 05/12] VRFS-2895 added unsubscribe link to email footer that doesnt require login --- .../app/views/layouts/user_mailer.html.erb | 2 +- .../app/views/layouts/user_mailer.text.erb | 2 +- ruby/lib/jam_ruby/models/user.rb | 22 +++++++++++++++++++ ruby/spec/mailers/user_mailer_spec.rb | 1 + ruby/spec/support/utilities.rb | 6 ++++- web/app/controllers/users_controller.rb | 13 +++++++++++ web/app/views/users/unsubscribe.html.haml | 12 ++++++++++ web/config/routes.rb | 2 ++ 8 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 web/app/views/users/unsubscribe.html.haml diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb index 3b11eebd1..4f184062f 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb @@ -39,7 +39,7 @@ -

    This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings. +

    This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings.

    diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb index 5c8262f63..78d40b50c 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb @@ -5,7 +5,7 @@ <% end %> <% unless @suppress_user_has_account_footer == true %> -This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/client#/account/profile. +This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/unsubscribe/<%=@user.unsubscribe_token%>. <% end %> Copyright <%= Time.now.year %> JamKazam, Inc. All rights reserved. diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 4d9b3b21a..2b966b895 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -1533,6 +1533,28 @@ module JamRuby ShoppingCart.where("user_id=?", self).destroy_all end + def unsubscribe_token + self.class.create_access_token(self) + end + + # Verifier based on our application secret + def self.verifier + ActiveSupport::MessageVerifier.new(APP_CONFIG.secret_token) + end + + # Get a user from a token + def self.read_access_token(signature) + uid = self.verifier.verify(signature) + User.find_by_id uid + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + # Class method for token generation + def self.create_access_token(user) + verifier.generate(user.id) + end + private def create_remember_token self.remember_token = SecureRandom.urlsafe_base64 diff --git a/ruby/spec/mailers/user_mailer_spec.rb b/ruby/spec/mailers/user_mailer_spec.rb index 62b1472c6..c3d041060 100644 --- a/ruby/spec/mailers/user_mailer_spec.rb +++ b/ruby/spec/mailers/user_mailer_spec.rb @@ -12,6 +12,7 @@ describe UserMailer do let(:user) { FactoryGirl.create(:user) } before(:each) do + stub_const("APP_CONFIG", app_config) UserMailer.deliveries.clear end diff --git a/ruby/spec/support/utilities.rb b/ruby/spec/support/utilities.rb index 94294b86c..61e2daca5 100644 --- a/ruby/spec/support/utilities.rb +++ b/ruby/spec/support/utilities.rb @@ -170,6 +170,10 @@ def app_config true end + def secret_token + 'foobar' + end + private @@ -240,4 +244,4 @@ end def friend(user1, user2) FactoryGirl.create(:friendship, user: user1, friend: user2) FactoryGirl.create(:friendship, user: user2, friend: user1) -end \ No newline at end of file +end diff --git a/web/app/controllers/users_controller.rb b/web/app/controllers/users_controller.rb index bcac17d3e..10619b08e 100644 --- a/web/app/controllers/users_controller.rb +++ b/web/app/controllers/users_controller.rb @@ -396,6 +396,19 @@ JS end end + def unsubscribe + unless @user = User.read_access_token(params[:user_token]) + redirect_to '/' + end if params[:user_token].present? + + if request.get? + + elsif request.post? + @user.subscribe_email = false + @user.save! + end + end + private def is_native_client diff --git a/web/app/views/users/unsubscribe.html.haml b/web/app/views/users/unsubscribe.html.haml new file mode 100644 index 000000000..158578179 --- /dev/null +++ b/web/app/views/users/unsubscribe.html.haml @@ -0,0 +1,12 @@ += provide(:title, 'Unsubscribe') + +- if request.get? + %h2 Unsubscribe from all JamKazam email for address #{@user} ? + %br + = form_tag("") do + = submit_tag('Unsubscribe') + = hidden_field_tag(:user_token, params[:user_token]) +- elsif request.post? + - if @user && ! @user.subscribe_email + %h2 You have been unsubscribed. + diff --git a/web/config/routes.rb b/web/config/routes.rb index b2e89c9d0..8d66bcc22 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -79,6 +79,8 @@ SampleApp::Application.routes.draw do match '/reset_password_token' => 'users#reset_password_token', :via => :get match '/reset_password_complete' => 'users#reset_password_complete', :via => :post + match '/unsubscribe/:user_token' => 'users#unsubscribe', via: [:get, :post] + # email update match '/confirm_email' => 'users#finalize_update_email', :as => 'confirm_email' # NOTE: if you change this, you break outstanding email changes because links in user inboxes are broken From b4d604dd3b64f3a08e36e484dbea61ebbb7e3183 Mon Sep 17 00:00:00 2001 From: Jonathan Kolyer Date: Sun, 12 Apr 2015 06:18:31 +0000 Subject: [PATCH 06/12] VRFS-2895 added unsubscribe link to email footer that doesnt require login --- .../app/views/layouts/user_mailer.html.erb | 2 +- .../app/views/layouts/user_mailer.text.erb | 2 +- ruby/lib/jam_ruby/models/user.rb | 22 +++++++++++++++++++ ruby/spec/mailers/user_mailer_spec.rb | 1 + ruby/spec/support/utilities.rb | 6 ++++- web/app/controllers/users_controller.rb | 13 +++++++++++ web/app/views/users/unsubscribe.html.haml | 12 ++++++++++ web/config/routes.rb | 2 ++ 8 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 web/app/views/users/unsubscribe.html.haml diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb index 3b11eebd1..4f184062f 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb @@ -39,7 +39,7 @@ -

    This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings. +

    This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings.

    diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb index 5c8262f63..78d40b50c 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb @@ -5,7 +5,7 @@ <% end %> <% unless @suppress_user_has_account_footer == true %> -This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/client#/account/profile. +This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/unsubscribe/<%=@user.unsubscribe_token%>. <% end %> Copyright <%= Time.now.year %> JamKazam, Inc. All rights reserved. diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 4d9b3b21a..2b966b895 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -1533,6 +1533,28 @@ module JamRuby ShoppingCart.where("user_id=?", self).destroy_all end + def unsubscribe_token + self.class.create_access_token(self) + end + + # Verifier based on our application secret + def self.verifier + ActiveSupport::MessageVerifier.new(APP_CONFIG.secret_token) + end + + # Get a user from a token + def self.read_access_token(signature) + uid = self.verifier.verify(signature) + User.find_by_id uid + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + # Class method for token generation + def self.create_access_token(user) + verifier.generate(user.id) + end + private def create_remember_token self.remember_token = SecureRandom.urlsafe_base64 diff --git a/ruby/spec/mailers/user_mailer_spec.rb b/ruby/spec/mailers/user_mailer_spec.rb index 62b1472c6..c3d041060 100644 --- a/ruby/spec/mailers/user_mailer_spec.rb +++ b/ruby/spec/mailers/user_mailer_spec.rb @@ -12,6 +12,7 @@ describe UserMailer do let(:user) { FactoryGirl.create(:user) } before(:each) do + stub_const("APP_CONFIG", app_config) UserMailer.deliveries.clear end diff --git a/ruby/spec/support/utilities.rb b/ruby/spec/support/utilities.rb index 94294b86c..61e2daca5 100644 --- a/ruby/spec/support/utilities.rb +++ b/ruby/spec/support/utilities.rb @@ -170,6 +170,10 @@ def app_config true end + def secret_token + 'foobar' + end + private @@ -240,4 +244,4 @@ end def friend(user1, user2) FactoryGirl.create(:friendship, user: user1, friend: user2) FactoryGirl.create(:friendship, user: user2, friend: user1) -end \ No newline at end of file +end diff --git a/web/app/controllers/users_controller.rb b/web/app/controllers/users_controller.rb index fbd431bc6..f8269db5a 100644 --- a/web/app/controllers/users_controller.rb +++ b/web/app/controllers/users_controller.rb @@ -413,6 +413,19 @@ JS end end + def unsubscribe + unless @user = User.read_access_token(params[:user_token]) + redirect_to '/' + end if params[:user_token].present? + + if request.get? + + elsif request.post? + @user.subscribe_email = false + @user.save! + end + end + private def is_native_client diff --git a/web/app/views/users/unsubscribe.html.haml b/web/app/views/users/unsubscribe.html.haml new file mode 100644 index 000000000..158578179 --- /dev/null +++ b/web/app/views/users/unsubscribe.html.haml @@ -0,0 +1,12 @@ += provide(:title, 'Unsubscribe') + +- if request.get? + %h2 Unsubscribe from all JamKazam email for address #{@user} ? + %br + = form_tag("") do + = submit_tag('Unsubscribe') + = hidden_field_tag(:user_token, params[:user_token]) +- elsif request.post? + - if @user && ! @user.subscribe_email + %h2 You have been unsubscribed. + diff --git a/web/config/routes.rb b/web/config/routes.rb index 7cdbce911..d327068e5 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -79,6 +79,8 @@ SampleApp::Application.routes.draw do match '/reset_password_token' => 'users#reset_password_token', :via => :get match '/reset_password_complete' => 'users#reset_password_complete', :via => :post + match '/unsubscribe/:user_token' => 'users#unsubscribe', via: [:get, :post] + # email update match '/confirm_email' => 'users#finalize_update_email', :as => 'confirm_email' # NOTE: if you change this, you break outstanding email changes because links in user inboxes are broken From 252beca0912003d6af6053c5dccd6c7e58fc2815 Mon Sep 17 00:00:00 2001 From: Jonathan Kolyer Date: Sun, 12 Apr 2015 06:18:31 +0000 Subject: [PATCH 07/12] VRFS-2895 added unsubscribe link to email footer that doesnt require login --- .../app/views/layouts/user_mailer.html.erb | 2 +- .../app/views/layouts/user_mailer.text.erb | 2 +- ruby/lib/jam_ruby/models/user.rb | 22 +++++++++++++++++++ ruby/spec/mailers/user_mailer_spec.rb | 1 + ruby/spec/support/utilities.rb | 6 ++++- web/app/controllers/users_controller.rb | 13 +++++++++++ web/app/views/users/unsubscribe.html.haml | 12 ++++++++++ web/config/routes.rb | 2 ++ 8 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 web/app/views/users/unsubscribe.html.haml diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb index 3b11eebd1..4f184062f 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb @@ -39,7 +39,7 @@ -

    This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings. +

    This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings.

    diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb index 5c8262f63..78d40b50c 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb @@ -5,7 +5,7 @@ <% end %> <% unless @suppress_user_has_account_footer == true %> -This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/client#/account/profile. +This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/unsubscribe/<%=@user.unsubscribe_token%>. <% end %> Copyright <%= Time.now.year %> JamKazam, Inc. All rights reserved. diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 5a94e03a3..be020d4b3 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -1537,6 +1537,28 @@ module JamRuby ShoppingCart.where("user_id=?", self).destroy_all end + def unsubscribe_token + self.class.create_access_token(self) + end + + # Verifier based on our application secret + def self.verifier + ActiveSupport::MessageVerifier.new(APP_CONFIG.secret_token) + end + + # Get a user from a token + def self.read_access_token(signature) + uid = self.verifier.verify(signature) + User.find_by_id uid + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + # Class method for token generation + def self.create_access_token(user) + verifier.generate(user.id) + end + private def create_remember_token self.remember_token = SecureRandom.urlsafe_base64 diff --git a/ruby/spec/mailers/user_mailer_spec.rb b/ruby/spec/mailers/user_mailer_spec.rb index 62b1472c6..c3d041060 100644 --- a/ruby/spec/mailers/user_mailer_spec.rb +++ b/ruby/spec/mailers/user_mailer_spec.rb @@ -12,6 +12,7 @@ describe UserMailer do let(:user) { FactoryGirl.create(:user) } before(:each) do + stub_const("APP_CONFIG", app_config) UserMailer.deliveries.clear end diff --git a/ruby/spec/support/utilities.rb b/ruby/spec/support/utilities.rb index 5bed19487..93222cf6c 100644 --- a/ruby/spec/support/utilities.rb +++ b/ruby/spec/support/utilities.rb @@ -178,6 +178,10 @@ def app_config true end + def secret_token + 'foobar' + end + private @@ -248,4 +252,4 @@ end def friend(user1, user2) FactoryGirl.create(:friendship, user: user1, friend: user2) FactoryGirl.create(:friendship, user: user2, friend: user1) -end \ No newline at end of file +end diff --git a/web/app/controllers/users_controller.rb b/web/app/controllers/users_controller.rb index fbd431bc6..f8269db5a 100644 --- a/web/app/controllers/users_controller.rb +++ b/web/app/controllers/users_controller.rb @@ -413,6 +413,19 @@ JS end end + def unsubscribe + unless @user = User.read_access_token(params[:user_token]) + redirect_to '/' + end if params[:user_token].present? + + if request.get? + + elsif request.post? + @user.subscribe_email = false + @user.save! + end + end + private def is_native_client diff --git a/web/app/views/users/unsubscribe.html.haml b/web/app/views/users/unsubscribe.html.haml new file mode 100644 index 000000000..158578179 --- /dev/null +++ b/web/app/views/users/unsubscribe.html.haml @@ -0,0 +1,12 @@ += provide(:title, 'Unsubscribe') + +- if request.get? + %h2 Unsubscribe from all JamKazam email for address #{@user} ? + %br + = form_tag("") do + = submit_tag('Unsubscribe') + = hidden_field_tag(:user_token, params[:user_token]) +- elsif request.post? + - if @user && ! @user.subscribe_email + %h2 You have been unsubscribed. + diff --git a/web/config/routes.rb b/web/config/routes.rb index b308d918a..7c89d8990 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -79,6 +79,8 @@ SampleApp::Application.routes.draw do match '/reset_password_token' => 'users#reset_password_token', :via => :get match '/reset_password_complete' => 'users#reset_password_complete', :via => :post + match '/unsubscribe/:user_token' => 'users#unsubscribe', via: [:get, :post] + # email update match '/confirm_email' => 'users#finalize_update_email', :as => 'confirm_email' # NOTE: if you change this, you break outstanding email changes because links in user inboxes are broken From ab2925ef88c2476c1aef2e95f915efb51309cc19 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sun, 12 Apr 2015 13:45:26 -0500 Subject: [PATCH 08/12] VRFS-2821 - payment history screen added --- .../models/recurly_transaction_web_hook.rb | 15 ++ ruby/lib/jam_ruby/models/sale.rb | 63 +++++- ruby/lib/jam_ruby/models/sale_line_item.rb | 48 ++++- ruby/lib/jam_ruby/models/user.rb | 4 + ruby/lib/jam_ruby/recurly_client.rb | 15 +- .../jam_ruby/models/sale_line_item_spec.rb | 41 ++++ ruby/spec/jam_ruby/models/sale_spec.rb | 40 ++++ .../jam_ruby/models/shopping_cart_spec.rb | 3 +- web/app/assets/javascripts/accounts.js | 9 +- .../accounts_payment_history_screen.js.coffee | 180 ++++++++++++++++++ web/app/assets/javascripts/jam_rest.js | 14 +- web/app/assets/javascripts/utils.js | 5 +- .../client/accountPaymentHistory.css.scss | 51 +++++ web/app/assets/stylesheets/client/client.css | 1 + .../assets/stylesheets/client/common.css.scss | 5 + .../stylesheets/client/jamtrack.css.scss | 4 - web/app/controllers/api_sales_controller.rb | 15 ++ web/app/views/api_sales/index.rabl | 7 + web/app/views/api_sales/show.rabl | 11 ++ web/app/views/api_users/show.rabl | 4 +- web/app/views/clients/_account.html.erb | 8 +- .../_account_payment_history.html.slim | 44 +++++ web/app/views/clients/index.html.erb | 4 + .../_jamtrackPaymentHistoryDialog.html.slim | 13 -- web/config/routes.rb | 3 + .../controllers/api_sales_controller_spec.rb | 57 ++++++ web/spec/factories.rb | 21 ++ web/spec/features/account_spec.rb | 21 ++ 28 files changed, 665 insertions(+), 41 deletions(-) create mode 100644 ruby/spec/jam_ruby/models/sale_line_item_spec.rb create mode 100644 web/app/assets/javascripts/accounts_payment_history_screen.js.coffee create mode 100644 web/app/assets/stylesheets/client/accountPaymentHistory.css.scss create mode 100644 web/app/controllers/api_sales_controller.rb create mode 100644 web/app/views/api_sales/index.rabl create mode 100644 web/app/views/api_sales/show.rabl create mode 100644 web/app/views/clients/_account_payment_history.html.slim create mode 100644 web/spec/controllers/api_sales_controller_spec.rb diff --git a/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb b/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb index 1d3232169..d94dca2e0 100644 --- a/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb +++ b/ruby/lib/jam_ruby/models/recurly_transaction_web_hook.rb @@ -2,6 +2,8 @@ module JamRuby class RecurlyTransactionWebHook < ActiveRecord::Base belongs_to :user, class_name: 'JamRuby::User' + belongs_to :sale_line_item, class_name: 'JamRuby::SaleLineItem', foreign_key: 'subscription_id', primary_key: 'recurly_subscription_uuid', inverse_of: :recurly_transactions + belongs_to :sale, class_name: 'JamRuby::Sale', foreign_key: 'invoice_id', primary_key: 'recurly_invoice_id', inverse_of: :recurly_transactions validates :recurly_transaction_id, presence: true validates :action, presence: true @@ -9,11 +11,24 @@ module JamRuby validates :amount_in_cents, numericality: {only_integer: true} validates :user, presence: true + SUCCESSFUL_PAYMENT = 'payment' FAILED_PAYMENT = 'failed_payment' REFUND = 'refund' VOID = 'void' + def is_credit_type? + transaction_type == REFUND || transaction_type == VOID + end + + def is_voided? + transaction_type == VOID + end + + def is_refund? + transaction_type == REFUND + end + def self.is_transaction_web_hook?(document) return false if document.root.nil? diff --git a/ruby/lib/jam_ruby/models/sale.rb b/ruby/lib/jam_ruby/models/sale.rb index 06addb8e3..cb2aba3b0 100644 --- a/ruby/lib/jam_ruby/models/sale.rb +++ b/ruby/lib/jam_ruby/models/sale.rb @@ -8,9 +8,66 @@ module JamRuby belongs_to :user, class_name: 'JamRuby::User' has_many :sale_line_items, class_name: 'JamRuby::SaleLineItem' + has_many :recurly_transactions, class_name: 'JamRuby::RecurlyTransactionWebHook', inverse_of: :sale, foreign_key: 'invoice_id', primary_key: 'recurly_invoice_id' + validates :order_total, numericality: {only_integer: false} validates :user, presence: true + @@log = Logging.logger[Sale] + + def self.index(user, params = {}) + + limit = params[:per_page] + limit ||= 20 + limit = limit.to_i + + query = Sale.limit(limit) + .includes([:recurly_transactions, :sale_line_items]) + .where('sales.user_id' => user.id) + .order('sales.created_at DESC') + + current_page = params[:page].nil? ? 1 : params[:page].to_i + next_page = current_page + 1 + + # will_paginate gem + query = query.paginate(:page => current_page, :per_page => limit) + + if query.length == 0 # no more results + { query: query, next_page: nil} + elsif query.length < limit # no more results + { query: query, next_page: nil} + else + { query: query, next_page: next_page } + end + + end + + def state + original_total = self.recurly_total_in_cents + + is_voided = false + refund_total = 0 + + recurly_transactions.each do |transaction| + if transaction.is_voided? + is_voided = true + else + + end + + if transaction.is_refund? + refund_total = refund_total + transaction.amount_in_cents + end + end + + # if refund_total is > 0, then you have a refund. + # if voided is true, then in theory the whole thing has been refunded + { + voided: is_voided, + original_total: original_total, + refund_total: refund_total + } + end def self.preview_invoice(current_user, shopping_carts) @@ -102,10 +159,6 @@ module JamRuby sale.recurly_total_in_cents = invoice.total_in_cents sale.recurly_currency = invoice.currency - puts "Sale Line Items #{sale.sale_line_items.inspect}" - - puts "----" - puts "Invoice Line Items #{invoice.line_items.inspect}" # and resolve against sale_line_items sale.sale_line_items.each do |sale_line_item| found_line_item = false @@ -120,7 +173,7 @@ module JamRuby end if !found_line_item - @@loge.error("can't find line item #{sale_line_item.recurly_adjustment_uuid}") + @@log.error("can't find line item #{sale_line_item.recurly_adjustment_uuid}") puts "CANT FIND LINE ITEM" end diff --git a/ruby/lib/jam_ruby/models/sale_line_item.rb b/ruby/lib/jam_ruby/models/sale_line_item.rb index 0685244f2..2022318b0 100644 --- a/ruby/lib/jam_ruby/models/sale_line_item.rb +++ b/ruby/lib/jam_ruby/models/sale_line_item.rb @@ -1,14 +1,15 @@ module JamRuby class SaleLineItem < ActiveRecord::Base - belongs_to :sale, class_name: 'JamRuby::Sale' - belongs_to :jam_track, class_name: 'JamRuby::JamTrack' - belongs_to :jam_track_right, class_name: 'JamRuby::JamTrackRight' - JAMBLASTER = 'JamBlaster' JAMCLOUD = 'JamCloud' JAMTRACK = 'JamTrack' + belongs_to :sale, class_name: 'JamRuby::Sale' + belongs_to :jam_track, class_name: 'JamRuby::JamTrack' + belongs_to :jam_track_right, class_name: 'JamRuby::JamTrackRight' + has_many :recurly_transactions, class_name: 'JamRuby::RecurlyTransactionWebHook', inverse_of: :sale_line_item, foreign_key: 'subscription_id', primary_key: 'recurly_subscription_uuid' + validates :product_type, inclusion: {in: [JAMBLASTER, JAMCLOUD, JAMTRACK]} validates :unit_price, numericality: {only_integer: false} validates :quantity, numericality: {only_integer: true} @@ -19,10 +20,45 @@ module JamRuby validates :sale, presence:true def product - # TODO: beef up if there is more than one sort of sale - JamTrack.find(product_id) + if product_type == JAMTRACK + JamTrack.find_by_id(product_id) + else + raise 'unsupported product type' + end end + def product_info + item = product + { name: product.name } if item + end + + def state + voided = false + refunded = false + failed = false + succeeded = false + + recurly_transactions.each do |transaction| + if transaction.transaction_type == RecurlyTransactionWebHook::VOID + voided = true + elsif transaction.transaction_type == RecurlyTransactionWebHook::REFUND + refunded = true + elsif transaction.transaction_type == RecurlyTransactionWebHook::FAILED_PAYMENT + failed = true + elsif transaction.transaction_type == RecurlyTransactionWebHook::SUCCESSFUL_PAYMENT + succeeded = true + end + end + + { + void: voided, + refund: refunded, + fail: failed, + success: succeeded + } + end + + def self.create_from_shopping_cart(sale, shopping_cart, recurly_subscription_uuid, recurly_adjustment_uuid, recurly_adjustment_credit_uuid) product_info = shopping_cart.product_info diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 4d9b3b21a..5a94e03a3 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -375,6 +375,10 @@ module JamRuby self.purchased_jam_tracks.count end + def sales_count + self.sales.count + end + def joined_score return nil unless has_attribute?(:score) a = read_attribute(:score) diff --git a/ruby/lib/jam_ruby/recurly_client.rb b/ruby/lib/jam_ruby/recurly_client.rb index 1ff419667..ec88cdaa5 100644 --- a/ruby/lib/jam_ruby/recurly_client.rb +++ b/ruby/lib/jam_ruby/recurly_client.rb @@ -61,12 +61,20 @@ module JamRuby account end - def payment_history(current_user) + def payment_history(current_user, options ={}) + + limit = params[:limit] + limit ||= 20 + limit = limit.to_i + + cursor = options[:cursor] + payments = [] account = get_account(current_user) if(account.present?) begin - account.transactions.find_each do |transaction| + + account.transaction.paginate(per_page:limit, cursor:cursor).each do |transaction| # XXX this isn't correct because we create 0 dollar transactions too (for free stuff) #if transaction.amount_in_cents > 0 # Account creation adds a transaction record payments << { @@ -74,7 +82,8 @@ module JamRuby :amount_in_cents => transaction.amount_in_cents, :status => transaction.status, :payment_method => transaction.payment_method, - :reference => transaction.reference + :reference => transaction.reference, + :plan_code => transaction.plan_code } #end end diff --git a/ruby/spec/jam_ruby/models/sale_line_item_spec.rb b/ruby/spec/jam_ruby/models/sale_line_item_spec.rb new file mode 100644 index 000000000..334166734 --- /dev/null +++ b/ruby/spec/jam_ruby/models/sale_line_item_spec.rb @@ -0,0 +1,41 @@ + +require 'spec_helper' + +describe SaleLineItem do + + let(:user) {FactoryGirl.create(:user)} + let(:user2) {FactoryGirl.create(:user)} + let(:jam_track) {FactoryGirl.create(:jam_track)} + + describe "associations" do + + it "can find associated recurly transaction web hook" do + sale = Sale.create_jam_track_sale(user) + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid', nil, nil) + transaction = FactoryGirl.create(:recurly_transaction_web_hook, subscription_id: 'some_recurly_uuid') + + sale_line_item.reload + sale_line_item.recurly_transactions.should eq([transaction]) + end + end + + + describe "state" do + + it "success" do + sale = Sale.create_jam_track_sale(user) + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, 'some_recurly_uuid', nil, nil) + transaction = FactoryGirl.create(:recurly_transaction_web_hook, subscription_id: 'some_recurly_uuid') + + sale_line_item.reload + sale_line_item.state.should eq({ + void: false, + refund: false, + fail: false, + success: true + }) + end + end +end diff --git a/ruby/spec/jam_ruby/models/sale_spec.rb b/ruby/spec/jam_ruby/models/sale_spec.rb index 91d298b32..b08aad36d 100644 --- a/ruby/spec/jam_ruby/models/sale_spec.rb +++ b/ruby/spec/jam_ruby/models/sale_spec.rb @@ -2,6 +2,46 @@ require 'spec_helper' describe Sale do + let(:user) {FactoryGirl.create(:user)} + let(:user2) {FactoryGirl.create(:user)} + let(:jam_track) {FactoryGirl.create(:jam_track)} + + describe "index" do + it "empty" do + result = Sale.index(user) + result[:query].length.should eq(0) + result[:next].should eq(nil) + end + + it "one" do + sale = Sale.create_jam_track_sale(user) + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil) + + result = Sale.index(user) + result[:query].length.should eq(1) + result[:next].should eq(nil) + end + + it "user filtered correctly" do + sale = Sale.create_jam_track_sale(user) + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil) + + result = Sale.index(user) + result[:query].length.should eq(1) + result[:next].should eq(nil) + + sale2 = Sale.create_jam_track_sale(user2) + shopping_cart = ShoppingCart.create(user2, jam_track) + sale_line_item2 = SaleLineItem.create_from_shopping_cart(sale2, shopping_cart, nil, 'some_adjustment_uuid', nil) + + result = Sale.index(user) + result[:query].length.should eq(1) + result[:next].should eq(nil) + end + end + describe "place_order" do diff --git a/ruby/spec/jam_ruby/models/shopping_cart_spec.rb b/ruby/spec/jam_ruby/models/shopping_cart_spec.rb index 9daf3d449..13f6777bc 100644 --- a/ruby/spec/jam_ruby/models/shopping_cart_spec.rb +++ b/ruby/spec/jam_ruby/models/shopping_cart_spec.rb @@ -24,9 +24,10 @@ describe ShoppingCart do it "should not add duplicate JamTrack to ShoppingCart" do cart1 = ShoppingCart.add_jam_track_to_cart(user, jam_track) cart1.should_not be_nil + cart1.errors.any?.should be_false user.reload cart2 = ShoppingCart.add_jam_track_to_cart(user, jam_track) - cart2.should be_nil + cart2.errors.any?.should be_true end diff --git a/web/app/assets/javascripts/accounts.js b/web/app/assets/javascripts/accounts.js index 5df29a6bd..b7af8f033 100644 --- a/web/app/assets/javascripts/accounts.js +++ b/web/app/assets/javascripts/accounts.js @@ -55,7 +55,8 @@ validProfiles : validProfiles, invalidProfiles : invalidProfiles, isNativeClient: gon.isNativeClient, - musician: context.JK.currentUserMusician + musician: context.JK.currentUserMusician, + sales_count: userDetail.sales_count } , { variable: 'data' })); $('#account-content-scroller').html($template); @@ -113,7 +114,7 @@ // License dialog: $("#account-content-scroller").on('click', '#account-view-license-link', function(evt) {evt.stopPropagation(); app.layout.showDialog('jamtrack-license-dialog'); return false; } ); - $("#account-content-scroller").on('click', '#account-payment-history-link', function(evt) {evt.stopPropagation(); app.layout.showDialog('jamtrack-payment-history-dialog'); return false; } ); + $("#account-content-scroller").on('click', '#account-payment-history-link', function(evt) {evt.stopPropagation(); navToPaymentHistory(); return false; } ); } function renderAccount() { @@ -157,6 +158,10 @@ window.location = "/client#/account/audio" } + function navToPaymentHistory() { + window.location = '/client#/account/paymentHistory' + } + // handle update avatar event function updateAvatar(avatar_url) { var photoUrl = context.JK.resolveAvatarUrl(avatar_url); diff --git a/web/app/assets/javascripts/accounts_payment_history_screen.js.coffee b/web/app/assets/javascripts/accounts_payment_history_screen.js.coffee new file mode 100644 index 000000000..2f20246cf --- /dev/null +++ b/web/app/assets/javascripts/accounts_payment_history_screen.js.coffee @@ -0,0 +1,180 @@ +$ = jQuery +context = window +context.JK ||= {} + +context.JK.AccountPaymentHistoryScreen = class AccountPaymentHistoryScreen + LIMIT = 20 + + constructor: (@app) -> + @logger = context.JK.logger + @rest = context.JK.Rest() + @screen = null + @scroller = null + @genre = null + @artist = null + @instrument = null + @availability = null + @nextPager = null + @noMoreSales = null + @currentPage = 0 + @next = null + @tbody = null + @rowTemplate = null + + beforeShow:(data) => + + + afterShow:(data) => + @refresh() + + events:() => + @backBtn.on('click', @onBack) + + onBack:() => + window.location = '/client#/account' + return false + + clearResults:() => + @currentPage = 0 + @tbody.empty() + @noMoreSales.hide() + @next = null + + + + refresh:() => + @currentQuery = this.buildQuery() + @rest.getSalesHistory(@currentQuery) + .done(@salesHistoryDone) + .fail(@salesHistoryFail) + + + renderPayments:(response) => + if response.entries? && response.entries.length > 0 + for sale in response.entries + amt = sale.recurly_total_in_cents + amt = 0 if !amt? + + original_total = sale.state.original_total + refund_total = sale.state.refund_total + + refund_state = null + if original_total != 0 # the enclosed logic does not work for free purchases + if refund_total == original_total + refund_state = 'refunded' + else if refund_total != 0 and refund_total < original_total + refund_state = 'partial refund' + + + displayAmount = (amt/100).toFixed(2) + status = 'paid' + + if sale.state.voided + status = 'voided' + displayAmount = (0).toFixed(2) + else if refund_state? + status = refund_state + displayAmount = (amt/100).toFixed(2) + " (refunded: #{(refund_total/100).toFixed(2)})" + + description = [] + for line_item in sale.line_items + description.push(line_item.product_info?.name) + + payment = { + date: context.JK.formatDate(sale.created_at, true) + amount: displayAmount + status: status + payment_method: 'Credit Card', + description: description.join(', ') + } + + tr = $(context._.template(@rowTemplate, payment, { variable: 'data' })); + @tbody.append(tr); + else + tr = "No payments found" + @tbody.append(tr); + + salesHistoryDone:(response) => + + # Turn in to HTML rows and append: + #@tbody.html("") + console.log("response.next", response) + @next = response.next_page + @renderPayments(response) + if response.next_page == null + # if we less results than asked for, end searching + @scroller.infinitescroll 'pause' + @logger.debug("end of history") + if @currentPage > 0 + @noMoreSales.show() + # there are bugs with infinitescroll not removing the 'loading'. + # it's most noticeable at the end of the list, so whack all such entries + $('.infinite-scroll-loader').remove() + else + @currentPage++ + this.buildQuery() + this.registerInfiniteScroll() + + + salesHistoryFail:(jqXHR)=> + @noMoreSales.show() + @app.notifyServerError jqXHR, 'Payment History Unavailable' + + defaultQuery:() => + query = + per_page: LIMIT + page: @currentPage+1 + if @next + query.since = @next + query + + buildQuery:() => + @currentQuery = this.defaultQuery() + + + registerInfiniteScroll:() => + that = this + @scroller.infinitescroll { + behavior: 'local' + navSelector: '#account-payment-history .btn-next-pager' + nextSelector: '#account-payment-history .btn-next-pager' + binder: @scroller + dataType: 'json' + appendCallback: false + prefill: false + bufferPx: 100 + loading: + msg: $('
    Loading ...
    ') + img: '/assets/shared/spinner.gif' + path: (page) => + '/api/sales?' + $.param(that.buildQuery()) + + }, (json, opts) => + this.salesHistoryDone(json) + @scroller.infinitescroll 'resume' + + initialize:() => + screenBindings = + 'beforeShow': this.beforeShow + 'afterShow': this.afterShow + @app.bindScreen 'account/paymentHistory', screenBindings + @screen = $('#account-payment-history') + @scroller = @screen.find('.content-body-scroller') + @nextPager = @screen.find('a.btn-next-pager') + @noMoreSales = @screen.find('.end-of-payments-list') + @tbody = @screen.find("table.payment-table tbody") + @rowTemplate = $('#template-payment-history-row').html() + @backBtn = @screen.find('.back') + + if @screen.length == 0 + throw new Error('@screen must be specified') + if @scroller.length == 0 + throw new Error('@scroller must be specified') + if @tbody.length == 0 + throw new Error('@tbody must be specified') + if @noMoreSales.length == 0 + throw new Error('@noMoreSales must be specified') + + this.events() + + diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 43dff7227..0cf7b6767 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -1499,9 +1499,18 @@ dataType: "json", contentType: 'application/json' }); - } + } - function getBackingTracks(options) { + function getSalesHistory(options) { + return $.ajax({ + type: "GET", + url: '/api/sales?' + $.param(options), + dataType: "json", + contentType: 'application/json' + }); + } + + function getBackingTracks(options) { return $.ajax({ type: "GET", url: '/api/backing_tracks?' + $.param(options), @@ -1765,6 +1774,7 @@ this.getJamtracks = getJamtracks; this.getPurchasedJamTracks = getPurchasedJamTracks; this.getPaymentHistory = getPaymentHistory; + this.getSalesHistory = getSalesHistory; this.getJamTrackRight = getJamTrackRight; this.enqueueJamTrack = enqueueJamTrack; this.getBackingTracks = getBackingTracks; diff --git a/web/app/assets/javascripts/utils.js b/web/app/assets/javascripts/utils.js index 3fb56cc43..b91ab71af 100644 --- a/web/app/assets/javascripts/utils.js +++ b/web/app/assets/javascripts/utils.js @@ -621,11 +621,12 @@ } // returns Fri May 20, 2013 - context.JK.formatDate = function (dateString) { + context.JK.formatDate = function (dateString, suppressDay) { var date = new Date(dateString); - return days[date.getDay()] + ' ' + months[date.getMonth()] + ' ' + context.JK.padString(date.getDate(), 2) + ', ' + date.getFullYear(); + return (suppressDay ? '' : (days[date.getDay()] + ' ')) + months[date.getMonth()] + ' ' + context.JK.padString(date.getDate(), 2) + ', ' + date.getFullYear(); } + context.JK.formatDateYYYYMMDD = function(dateString) { var date = new Date(dateString); return date.getFullYear() + '-' + context.JK.padString((date.getMonth() + 1).toString(), 2) + '-' + context.JK.padString(date.getDate(), 2); diff --git a/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss b/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss new file mode 100644 index 000000000..f0a7c1fdf --- /dev/null +++ b/web/app/assets/stylesheets/client/accountPaymentHistory.css.scss @@ -0,0 +1,51 @@ +@import 'common.css.scss'; + +#account-payment-history { + + .content-body-scroller { + padding:20px; + @include border_box_sizing; + } + + table td.loading { + text-align:center; + } + + .end-of-list { + margin-top:20px; + } + td { + + &.amount { + } + + &.voided { + + text-decoration:line-through; + } + } + + .account-left { + float: left; + min-width: 165px; + width: 20%; + } + + .account-left h2 { + color: #FFFFFF; + font-size: 23px; + font-weight: 400; + margin-bottom: 20px; + } + + .input-aligner { + margin: 10px 14px 20px 0; + text-align:right; + + .back { + margin-right:22px; + } + } + + +} \ No newline at end of file diff --git a/web/app/assets/stylesheets/client/client.css b/web/app/assets/stylesheets/client/client.css index 28e2f4ed5..5bb85acaa 100644 --- a/web/app/assets/stylesheets/client/client.css +++ b/web/app/assets/stylesheets/client/client.css @@ -31,6 +31,7 @@ *= require ./findSession *= require ./session *= require ./account + *= require ./accountPaymentHistory *= require ./search *= require ./ftue *= require ./jamServer diff --git a/web/app/assets/stylesheets/client/common.css.scss b/web/app/assets/stylesheets/client/common.css.scss index dbaaa80cf..75edd1bc1 100644 --- a/web/app/assets/stylesheets/client/common.css.scss +++ b/web/app/assets/stylesheets/client/common.css.scss @@ -330,3 +330,8 @@ $fair: #cc9900; border-radius:8px; } + +.capitalize { + text-transform: capitalize +} + diff --git a/web/app/assets/stylesheets/client/jamtrack.css.scss b/web/app/assets/stylesheets/client/jamtrack.css.scss index b9ed8314b..979ac5377 100644 --- a/web/app/assets/stylesheets/client/jamtrack.css.scss +++ b/web/app/assets/stylesheets/client/jamtrack.css.scss @@ -240,8 +240,4 @@ .jamtrack_buttons { margin: 8px 4px 12px 4px; -} - -.capitalize { - text-transform: capitalize } \ No newline at end of file diff --git a/web/app/controllers/api_sales_controller.rb b/web/app/controllers/api_sales_controller.rb new file mode 100644 index 000000000..8886ba6bd --- /dev/null +++ b/web/app/controllers/api_sales_controller.rb @@ -0,0 +1,15 @@ +class ApiSalesController < ApiController + + respond_to :json + + def index + data = Sale.index(current_user, + page: params[:page], + per_page: params[:per_page]) + + + @sales = data[:query] + @next = data[:next_page] + render "api_sales/index", :layout => nil + end +end \ No newline at end of file diff --git a/web/app/views/api_sales/index.rabl b/web/app/views/api_sales/index.rabl new file mode 100644 index 000000000..1b61714f5 --- /dev/null +++ b/web/app/views/api_sales/index.rabl @@ -0,0 +1,7 @@ +node :next_page do |page| + @next +end + +node :entries do |page| + partial "api_sales/show", object: @sales +end \ No newline at end of file diff --git a/web/app/views/api_sales/show.rabl b/web/app/views/api_sales/show.rabl new file mode 100644 index 000000000..d6d7129a6 --- /dev/null +++ b/web/app/views/api_sales/show.rabl @@ -0,0 +1,11 @@ +object @sale + +attributes :id, :recurly_invoice_id, :recurly_subtotal_in_cents, :recurly_tax_in_cents, :recurly_total_in_cents, :recurly_currency, :sale_type, :recurly_invoice_number, :state, :created_at + +child(:recurly_transactions => :recurly_transactions) { + attributes :transaction_type, :amount_in_cents +} + +child(:sale_line_items => :line_items) { + attributes :id, :product_info +} diff --git a/web/app/views/api_users/show.rabl b/web/app/views/api_users/show.rabl index 2e50c7dcb..32c79fee0 100644 --- a/web/app/views/api_users/show.rabl +++ b/web/app/views/api_users/show.rabl @@ -1,6 +1,6 @@ object @user -attributes :id, :first_name, :last_name, :name, :city, :state, :country, :location, :online, :photo_url, :musician, :gender, :birth_date, :internet_service_provider, :friend_count, :liker_count, :like_count, :follower_count, :following_count, :recording_count, :session_count, :biography, :favorite_count, :audio_latency, :upcoming_session_count, :reuse_card, :purchased_jamtracks_count +attributes :id, :first_name, :last_name, :name, :city, :state, :country, :location, :online, :photo_url, :musician, :gender, :birth_date, :internet_service_provider, :friend_count, :liker_count, :like_count, :follower_count, :following_count, :recording_count, :session_count, :biography, :favorite_count, :audio_latency, :upcoming_session_count if @user.musician? node :location do @user.location end @@ -10,7 +10,7 @@ end # give back more info if the user being fetched is yourself if @user == current_user - attributes :email, :original_fpfile, :cropped_fpfile, :crop_selection, :session_settings, :show_whats_next, :show_whats_next_count, :subscribe_email, :auth_twitter, :new_notifications + attributes :email, :original_fpfile, :cropped_fpfile, :crop_selection, :session_settings, :show_whats_next, :show_whats_next_count, :subscribe_email, :auth_twitter, :new_notifications, :sales_count, :reuse_card, :purchased_jamtracks_count node :geoiplocation do |user| geoiplocation = current_user.geoiplocation diff --git a/web/app/views/clients/_account.html.erb b/web/app/views/clients/_account.html.erb index a630ef918..ceb703681 100644 --- a/web/app/views/clients/_account.html.erb +++ b/web/app/views/clients/_account.html.erb @@ -118,7 +118,13 @@
    diff --git a/web/app/views/clients/_account_payment_history.html.slim b/web/app/views/clients/_account_payment_history.html.slim new file mode 100644 index 000000000..5a31dffaf --- /dev/null +++ b/web/app/views/clients/_account_payment_history.html.slim @@ -0,0 +1,44 @@ +.screen.secondary layout="screen" layout-id="account/paymentHistory" class="screen secondary" id="account-payment-history" + + .content + .content-head + .content-icon=image_tag("content/icon_account.png", height:20, width:27 ) + h1 my account + =render "screen_navigation" + .content-body + .content-body-scroller + + .account-left + h2 payment history: + table.payment-table + thead + tr + th DATE + th METHOD + th DESCRIPTION + th STATUS + th AMOUNT + tbody + a.btn-next-pager href="/api/sales?page=1" Next + .end-of-payments-list.end-of-list="No more payment history" + + + .input-aligner + a.back href="" class="button-grey" BACK + br clear="all" + + + + +script#template-payment-history-row type="text/template" + tr + td + | {{data.date}} + td.capitalize + | {{data.payment_method}} + td + | {{data.description}} + td.capitalize + | {{data.status}} + td.amount class="{{data.status}}" + | ${{data.amount}} diff --git a/web/app/views/clients/index.html.erb b/web/app/views/clients/index.html.erb index 958c6aa99..fe8b7a103 100644 --- a/web/app/views/clients/index.html.erb +++ b/web/app/views/clients/index.html.erb @@ -58,6 +58,7 @@ <%= render "account_jamtracks" %> <%= render "account_session_detail" %> <%= render "account_session_properties" %> +<%= render "account_payment_history" %> <%= render "inviteMusicians" %> <%= render "hoverBand" %> <%= render "hoverFan" %> @@ -214,6 +215,9 @@ var accountAudioProfile = new JK.AccountAudioProfile(JK.app); accountAudioProfile.initialize(); + var accountPaymentHistoryScreen = new JK.AccountPaymentHistoryScreen(JK.app); + accountPaymentHistoryScreen.initialize(); + var searchResultScreen = new JK.SearchResultScreen(JK.app); searchResultScreen.initialize(); diff --git a/web/app/views/dialogs/_jamtrackPaymentHistoryDialog.html.slim b/web/app/views/dialogs/_jamtrackPaymentHistoryDialog.html.slim index 663d0f939..64b0918e2 100644 --- a/web/app/views/dialogs/_jamtrackPaymentHistoryDialog.html.slim +++ b/web/app/views/dialogs/_jamtrackPaymentHistoryDialog.html.slim @@ -18,16 +18,3 @@ .jamtrack_buttons .right a.button-orange class='btnCancel' layout-action='cancel' OK - - script#template-payment-history-row type="text/template" - tr - td - | {{data.date}} - td - | ${{data.amount}} - td.capitalize - | {{data.status}} - td.capitalize - | {{data.payment_method}} - td - | {{data.reference}} diff --git a/web/config/routes.rb b/web/config/routes.rb index 7cdbce911..b308d918a 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -276,6 +276,9 @@ SampleApp::Application.routes.draw do match '/recurly/update_billing_info' => 'api_recurly#update_billing_info', :via => :put match '/recurly/place_order' => 'api_recurly#place_order', :via => :post + # sale info + match '/sales' => 'api_sales#index', :via => :get + # login/logout match '/auth_session' => 'api_users#auth_session_create', :via => :post match '/auth_session' => 'api_users#auth_session_delete', :via => :delete diff --git a/web/spec/controllers/api_sales_controller_spec.rb b/web/spec/controllers/api_sales_controller_spec.rb new file mode 100644 index 000000000..e1e39bf32 --- /dev/null +++ b/web/spec/controllers/api_sales_controller_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe ApiSalesController do + render_views + + let(:user) {FactoryGirl.create(:user)} + let(:jam_track) {FactoryGirl.create(:jam_track)} + + before(:each) do + controller.current_user = user + end + + describe "index" do + + it "empty" do + get :index, { :format => 'json'} + + response.should be_success + body = JSON.parse(response.body) + body['next_page'].should be_nil + body['entries'].should eq([]) + end + + it "one item" do + sale = Sale.create_jam_track_sale(user) + sale.recurly_invoice_id = SecureRandom.uuid + sale.save! + + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil) + + get :index, { :format => 'json'} + response.should be_success + body = JSON.parse(response.body) + body['next_page'].should be_nil + entries = body['entries'] + entries.should have(1).items + sale_entry = entries[0] + sale_entry["line_items"].should have(1).items + sale_entry["recurly_transactions"].should have(0).items + + + transaction = FactoryGirl.create(:recurly_transaction_web_hook, invoice_id: sale.recurly_invoice_id, transaction_type: RecurlyTransactionWebHook::VOID) + + get :index, { :format => 'json'} + response.should be_success + body = JSON.parse(response.body) + body['next_page'].should be_nil + entries = body['entries'] + entries.should have(1).items + sale_entry = entries[0] + sale_entry["line_items"].should have(1).items + sale_entry["recurly_transactions"].should have(1).items + end + end + +end diff --git a/web/spec/factories.rb b/web/spec/factories.rb index d625ec53c..ca49f36eb 100644 --- a/web/spec/factories.rb +++ b/web/spec/factories.rb @@ -756,4 +756,25 @@ FactoryGirl.define do bpm 120 tap_in_count 3 end + + factory :recurly_transaction_web_hook, :class => JamRuby::RecurlyTransactionWebHook do + + transaction_type JamRuby::RecurlyTransactionWebHook::SUCCESSFUL_PAYMENT + sequence(:recurly_transaction_id ) { |n| "recurly-transaction-id-#{n}" } + sequence(:subscription_id ) { |n| "subscription-id-#{n}" } + sequence(:invoice_id ) { |n| "invoice-id-#{n}" } + sequence(:invoice_number ) { |n| 1000 + n } + invoice_number_prefix nil + action 'purchase' + status 'success' + transaction_at Time.now + amount_in_cents 199 + reference 100000 + message 'meh' + association :user, factory: :user + + factory :recurly_transaction_web_hook_failed do + transaction_type JamRuby::RecurlyTransactionWebHook::FAILED_PAYMENT + end + end end diff --git a/web/spec/features/account_spec.rb b/web/spec/features/account_spec.rb index 80c2ab1b0..15b7974aa 100644 --- a/web/spec/features/account_spec.rb +++ b/web/spec/features/account_spec.rb @@ -5,6 +5,7 @@ describe "Account", :js => true, :type => :feature, :capybara_feature => true do subject { page } let(:user) { FactoryGirl.create(:user) } + let(:jam_track) {FactoryGirl.create(:jam_track)} before(:each) do UserMailer.deliveries.clear @@ -135,6 +136,26 @@ describe "Account", :js => true, :type => :feature, :capybara_feature => true do } end end + end + + describe "payment history" do + + it "show 1 sale" do + + sale = Sale.create_jam_track_sale(user) + shopping_cart = ShoppingCart.create(user, jam_track) + sale_line_item = SaleLineItem.create_from_shopping_cart(sale, shopping_cart, nil, 'some_adjustment_uuid', nil) + + visit "/client#/account" + + find('.account-mid.payments', text: 'You have made 1 purchase.') + + find("#account-payment-history-link").trigger(:click) + find('h2', text: 'payment history:') + find('table tr td', text: '$0.00') # 1st purchase is free + + end + end From b1b6686d3a8bdefb6efe3c762d0741160579846e Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sun, 12 Apr 2015 15:55:48 -0500 Subject: [PATCH 09/12] * VRFS-2976 - change default to 10/2/2 for WDM --- web/app/assets/javascripts/wizard/gear_utils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/assets/javascripts/wizard/gear_utils.js b/web/app/assets/javascripts/wizard/gear_utils.js index 04716dde1..051ca1949 100644 --- a/web/app/assets/javascripts/wizard/gear_utils.js +++ b/web/app/assets/javascripts/wizard/gear_utils.js @@ -214,9 +214,9 @@ frameBuffers.setBufferOut('2'); } else { - logger.debug("setting default buffers to 6/5"); - frameBuffers.setBufferIn('6'); - frameBuffers.setBufferOut('5'); + logger.debug("setting default buffers to 2/2"); + frameBuffers.setBufferIn('2'); + frameBuffers.setBufferOut('2'); } } else { From 0dc77b400ac264bc6f6a5dc8847a651eb6322cbc Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sun, 12 Apr 2015 16:22:01 -0500 Subject: [PATCH 10/12] * VRFS-3006 - remove 'No audio loaded' when jamtrack is loading, and VRFS-3016 - no more text wrapping in error msg --- web/app/assets/javascripts/session.js | 2 ++ web/app/assets/stylesheets/client/downloadJamTrack.css.scss | 1 + 2 files changed, 3 insertions(+) diff --git a/web/app/assets/javascripts/session.js b/web/app/assets/javascripts/session.js index e1b191104..dfa76dc65 100644 --- a/web/app/assets/javascripts/session.js +++ b/web/app/assets/javascripts/session.js @@ -2650,6 +2650,8 @@ var jamTrack = data.result.jamTrack; + $('.session-recording-name').text(''); + // hide 'other audio' placeholder otherAudioFilled(); diff --git a/web/app/assets/stylesheets/client/downloadJamTrack.css.scss b/web/app/assets/stylesheets/client/downloadJamTrack.css.scss index 27eb232a0..1cae87032 100644 --- a/web/app/assets/stylesheets/client/downloadJamTrack.css.scss +++ b/web/app/assets/stylesheets/client/downloadJamTrack.css.scss @@ -10,6 +10,7 @@ .retry { margin-top:10px; + white-space: normal; } .msg { From 34d50f4330a9f0927d1d945c4885b5edbdcc8791 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Sun, 12 Apr 2015 16:53:24 -0500 Subject: [PATCH 11/12] * VRFS-3025 - vertically align recording elements even when scrollbar is present --- web/app/assets/stylesheets/client/session.css.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/assets/stylesheets/client/session.css.scss b/web/app/assets/stylesheets/client/session.css.scss index 700758035..21720a20b 100644 --- a/web/app/assets/stylesheets/client/session.css.scss +++ b/web/app/assets/stylesheets/client/session.css.scss @@ -68,8 +68,8 @@ vertical-align:top; } - .session-recordedtracks-container { - //display: block; + .recording { + top:310px; // // this is to prevent scroll bars from pushing this element up } .recording-controls { From fa725af5e461c2c0f055129b1be18cbed25a53d3 Mon Sep 17 00:00:00 2001 From: Seth Call Date: Mon, 13 Apr 2015 09:56:48 -0500 Subject: [PATCH 12/12] * VRFS-2945 - jamtrack countin spinner and text now showing --- .../assets/javascripts/playbackControls.js | 17 +++++++++++- .../stylesheets/client/session.css.scss | 26 +++++++++++++++++++ web/app/views/clients/_play_controls.html.erb | 4 +++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/web/app/assets/javascripts/playbackControls.js b/web/app/assets/javascripts/playbackControls.js index d56a1e214..1a90712a6 100644 --- a/web/app/assets/javascripts/playbackControls.js +++ b/web/app/assets/javascripts/playbackControls.js @@ -34,6 +34,7 @@ var $sliderBar = $('.recording-playback', $parentElement); var $slider = $('.recording-slider', $parentElement); var $playmodeButton = $('.playback-mode-buttons.icheckbuttons input', $parentElement); + var $jamTrackGetReady = $('.jam-track-get-ready', $parentElement); var $self = $(this); @@ -158,7 +159,9 @@ setPlaybackMode(playmode); }); - function styleControls( ) { + function styleControls() { + $jamTrackGetReady.attr('data-mode', playbackMonitorMode); + $parentElement.removeClass('mediafile-mode jamtrack-mode metronome-mode'); if(playbackMonitorMode == PLAYBACK_MONITOR_MODE.MEDIA_FILE) { $parentElement.addClass('mediafile-mode'); @@ -194,6 +197,18 @@ positionMs = 0; } + if(playbackMonitorMode = PLAYBACK_MONITOR_MODE.JAMTRACK) { + + if(isPlaying) { + $jamTrackGetReady.attr('data-current-time', positionMs) + } + else { + // this is so the jamtrack 'Get Ready!' stays hidden when it's not playing + $jamTrackGetReady.attr('data-current-time', -1) + } + + } + if(playbackMonitorMode == PLAYBACK_MONITOR_MODE.METRONOME) { updateIsPlaying(isPlaying); } diff --git a/web/app/assets/stylesheets/client/session.css.scss b/web/app/assets/stylesheets/client/session.css.scss index 21720a20b..94228e6f0 100644 --- a/web/app/assets/stylesheets/client/session.css.scss +++ b/web/app/assets/stylesheets/client/session.css.scss @@ -84,6 +84,32 @@ .recording-current { top:3px ! important; } + + .jam-track-get-ready { + display:none; + position:absolute; + top:-29px; + margin-left:-50px; + width:100px; + vertical-align:middle; + height:32px; + line-height:32px; + left:50%; + + &[data-mode="JAMTRACK"] { + &[data-current-time="0"] { + display:block; + } + } + .spinner-small { + vertical-align:middle; + display:inline-block; + } + + span { + vertical-align:middle; + } + } } .playback-mode-buttons { diff --git a/web/app/views/clients/_play_controls.html.erb b/web/app/views/clients/_play_controls.html.erb index 535e25f07..671b1701f 100644 --- a/web/app/views/clients/_play_controls.html.erb +++ b/web/app/views/clients/_play_controls.html.erb @@ -1,6 +1,10 @@