Add a visible free plan. Tweak the plans and pricing page. Move all plans to a central plans service to have a single point for editing. Support the free plan on the user admin page. Tweak the landing page.
This commit is contained in:
parent
8d40f12165
commit
3eca5f65e1
7 changed files with 194 additions and 139 deletions
|
@ -383,41 +383,43 @@ def subscribe():
|
|||
else:
|
||||
# Change the plan
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
cus.plan = plan
|
||||
|
||||
# User may have been a previous customer who is resubscribing
|
||||
if 'token' in request_data:
|
||||
cus.card = request_data['token']
|
||||
if plan == 'free':
|
||||
cus.cancel_subscription()
|
||||
cus.save()
|
||||
|
||||
cus.save()
|
||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||
response_json = {
|
||||
'plan': 'free',
|
||||
'usedPrivateRepos': private_repos,
|
||||
}
|
||||
|
||||
else:
|
||||
cus.plan = plan
|
||||
|
||||
# User may have been a previous customer who is resubscribing
|
||||
if 'token' in request_data:
|
||||
cus.card = request_data['token']
|
||||
|
||||
cus.save()
|
||||
|
||||
response_json = subscription_view(cus.subscription, private_repos)
|
||||
|
||||
return jsonify(response_json)
|
||||
|
||||
|
||||
@app.route('/api/user/plan', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_subscription():
|
||||
user = current_user.db_user
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
|
||||
if user.stripe_id:
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
|
||||
if cus.subscription:
|
||||
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||
|
||||
abort(404)
|
||||
|
||||
|
||||
|
||||
@app.route('/api/user/plan', methods=['DELETE'])
|
||||
@api_login_required
|
||||
def cancel_subscription():
|
||||
user = current_user.db_user
|
||||
|
||||
if user.stripe_id:
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
cus.cancel_subscription()
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(404)
|
||||
return jsonify({
|
||||
'plan': 'free',
|
||||
'usedPrivateRepos': private_repos,
|
||||
});
|
||||
|
|
|
@ -5,13 +5,23 @@
|
|||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.plans .all-plans {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.plans .all-plans .feature {
|
||||
color: #428bca;
|
||||
}
|
||||
|
||||
.plans-list {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.plans-list .plan {
|
||||
width: 245px;
|
||||
height: 260px;
|
||||
vertical-align: top;
|
||||
|
||||
display: inline-block;
|
||||
|
@ -67,16 +77,26 @@
|
|||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.plans-list .plan.focus {
|
||||
.plans-list .plan .features {
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plans-list .plan .features i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
|
||||
.plans-list .plan.small {
|
||||
border: 1px solid #ddd;
|
||||
border-top: 4px solid #428bca;
|
||||
margin-top: 0px;
|
||||
font-size: 1.6em;
|
||||
height: 270px;
|
||||
}
|
||||
|
||||
.plans-list .plan button {
|
||||
|
||||
.plans .plan-faq dd{
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
@ -608,6 +628,10 @@ p.editable:hover i {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.user-admin .panel-plan .button-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.user-admin .plan-description {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 10px;
|
||||
|
|
|
@ -34,7 +34,58 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
userService.load();
|
||||
|
||||
return userService;
|
||||
}])
|
||||
}]);
|
||||
|
||||
$provide.factory('PlanService', [function() {
|
||||
var plans = [
|
||||
{
|
||||
title: 'Open Source',
|
||||
price: 0,
|
||||
privateRepos: 0,
|
||||
stripeId: 'free',
|
||||
audience: 'Share with the world',
|
||||
},
|
||||
{
|
||||
title: 'Micro',
|
||||
price: 700,
|
||||
privateRepos: 5,
|
||||
stripeId: 'micro',
|
||||
audience: 'For smaller teams',
|
||||
},
|
||||
{
|
||||
title: 'Basic',
|
||||
price: 1200,
|
||||
privateRepos: 10,
|
||||
stripeId: 'small',
|
||||
audience: 'For your basic team',
|
||||
},
|
||||
{
|
||||
title: 'Medium',
|
||||
price: 2200,
|
||||
privateRepos: 20,
|
||||
stripeId: 'medium',
|
||||
audience: 'For medium-sized teams',
|
||||
},
|
||||
];
|
||||
|
||||
var planDict = {};
|
||||
var i;
|
||||
for(i = 0; i < plans.length; i++) {
|
||||
planDict[plans[i].stripeId] = plans[i];
|
||||
}
|
||||
|
||||
var planService = {}
|
||||
|
||||
planService.planList = function() {
|
||||
return plans;
|
||||
}
|
||||
|
||||
planService.getPlan = function(planId) {
|
||||
return planDict[planId];
|
||||
}
|
||||
|
||||
return planService;
|
||||
}]);
|
||||
}).
|
||||
directive('match', function($parse) {
|
||||
return {
|
||||
|
|
|
@ -77,7 +77,9 @@ function HeaderCtrl($scope, UserService) {
|
|||
});
|
||||
}
|
||||
|
||||
function PlansCtrl($scope, UserService) {
|
||||
function PlansCtrl($scope, UserService, PlanService) {
|
||||
$scope.plans = PlanService.planList();
|
||||
|
||||
$scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) {
|
||||
$scope.user = currentUser;
|
||||
}, true);
|
||||
|
@ -440,38 +442,18 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
|||
});
|
||||
}
|
||||
|
||||
function UserAdminCtrl($scope, Restangular) {
|
||||
$scope.plans = [
|
||||
{
|
||||
title: 'Micro',
|
||||
price: 700,
|
||||
privateRepos: 5,
|
||||
stripeId: 'micro',
|
||||
},
|
||||
{
|
||||
title: 'Basic',
|
||||
price: 1200,
|
||||
privateRepos: 10,
|
||||
stripeId: 'small',
|
||||
},
|
||||
{
|
||||
title: 'Medium',
|
||||
price: 2200,
|
||||
privateRepos: 20,
|
||||
stripeId: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
var planDict = {};
|
||||
var i;
|
||||
for(i = 0; i < $scope.plans.length; i++) {
|
||||
planDict[$scope.plans[i].stripeId] = $scope.plans[i];
|
||||
}
|
||||
function UserAdminCtrl($scope, Restangular, PlanService, $routeParams) {
|
||||
$scope.plans = PlanService.planList();
|
||||
|
||||
var subscribedToPlan = function(sub) {
|
||||
$scope.subscription = sub;
|
||||
$scope.subscribedPlan = planDict[sub.plan];
|
||||
$scope.subscribedPlan = PlanService.getPlan(sub.plan);
|
||||
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
||||
|
||||
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
|
||||
$scope.errorMessage = 'You are using more private repositories than your plan allows, please upgrate your subscription to avoid disruptions in your service.';
|
||||
}
|
||||
|
||||
$scope.planLoading = false;
|
||||
$scope.planChanging = false;
|
||||
}
|
||||
|
@ -505,8 +487,7 @@ function UserAdminCtrl($scope, Restangular) {
|
|||
});
|
||||
};
|
||||
|
||||
console.log('Got request for plan: ' + planId);
|
||||
var planDetails = planDict[planId]
|
||||
var planDetails = PlanService.getPlan(planId)
|
||||
StripeCheckout.open({
|
||||
key: 'pk_test_uEDHANKm9CHCvVa2DLcipGRh',
|
||||
address: false, // TODO change to true
|
||||
|
@ -536,34 +517,14 @@ function UserAdminCtrl($scope, Restangular) {
|
|||
};
|
||||
|
||||
$scope.cancelSubscription = function() {
|
||||
$scope.planChanging = true;
|
||||
$scope.errorMessage = undefined;
|
||||
var unsubscribeRequest = Restangular.one('user/plan');
|
||||
unsubscribeRequest.customDELETE().then(function() {
|
||||
$scope.subscription = undefined;
|
||||
$scope.subscribedPlan = undefined;
|
||||
$scope.planUsagePercent = 0;
|
||||
$scope.planChanging = false;
|
||||
}, function() {
|
||||
// Failure
|
||||
$scope.errorMessage = 'Unable to unsubscribe.';
|
||||
$scope.planChanging = false;
|
||||
});
|
||||
$scope.changeSubscription('free');
|
||||
};
|
||||
|
||||
// Show the subscribe dialog if a plan was requested.
|
||||
if (location.hash.indexOf('?') >= 0) {
|
||||
var query = location.hash.substr(1).split('?')[1];
|
||||
var data = query.split("&");
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var item = data[i].split("=");
|
||||
if (item[0] == 'plan') {
|
||||
var planId = item[1];
|
||||
if (planDict[planId]) {
|
||||
$scope.subscribe(planId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
var requested = $routeParams['plan']
|
||||
if (requested !== undefined && requested !== 'free') {
|
||||
if (PlanService.getPlan(requested) !== undefined) {
|
||||
$scope.subscribe(requested);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
<div ng-show="user.anonymous">
|
||||
<h1>Secure hosting for <b>private</b> docker containers</h1>
|
||||
<h3>Use the docker images <b>your team</b> needs with the safety of <b>private</b> storage</h3>
|
||||
<div class="sellcall"><a href="#/plans">Starting at $7/mo</a></div>
|
||||
<div class="sellcall"><a href="#/plans">Private repository plans starting at $7/mo</a></div>
|
||||
</div>
|
||||
|
||||
<div ng-show="!user.anonymous">
|
||||
|
@ -41,7 +41,10 @@
|
|||
<input type="email" class="form-control" placeholder="Email address" ng-model="newUser.email" required>
|
||||
<input type="password" class="form-control" placeholder="Create a password" ng-model="newUser.password" required>
|
||||
<input type="password" class="form-control" placeholder="Verify your password" ng-model="newUser.repeatePassword" match="newUser.password" required>
|
||||
<button class="btn btn-lg btn-primary btn-block" ng-disabled="signupForm.$invalid" type="submit">Get Started!</button>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-lg btn-primary btn-block" ng-disabled="signupForm.$invalid" type="submit">Sign Up for Free!</button>
|
||||
<p class="help-block">No credit card required.</p>
|
||||
</div>
|
||||
</form>
|
||||
<div ng-show="registering" style="text-align: center">
|
||||
<span class="spin" color="#fff" style="display: inline-block"></span>
|
||||
|
@ -67,15 +70,15 @@
|
|||
</div>
|
||||
|
||||
<div class="col-md-4 shoutout">
|
||||
<i class="icon-cloud"></i>
|
||||
<b>Cloud Hosted</b>
|
||||
Accessible from anywhere, anytime
|
||||
<i class="icon-user"></i>
|
||||
<b>Shareable</b>
|
||||
Have to share a container? No problem! Share with anyone you choose
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 shoutout">
|
||||
<i class="icon-share-sign"></i>
|
||||
<b>Shareable</b>
|
||||
Have to share a container? No problem! Share with anyone you choose
|
||||
<i class="icon-cloud"></i>
|
||||
<b>Cloud Hosted</b>
|
||||
Accessible from anywhere, anytime
|
||||
</div>
|
||||
</div> <!-- row -->
|
||||
</div> <!-- container -->
|
||||
|
|
|
@ -1,52 +1,59 @@
|
|||
<div class="container plans">
|
||||
<div class="callout">
|
||||
Plans and Pricing
|
||||
Plans & Pricing
|
||||
</div>
|
||||
|
||||
<div class="all-plans">
|
||||
All plans include <span class="feature">unlimited public repositories</span> and <span class="feature">unlimited sharing</span>. All paid plans have a <span class="feature">14-day free trial</span>.
|
||||
</div>
|
||||
|
||||
<div class="plans-list">
|
||||
<div class="plan">
|
||||
<div class="plan-title">Micro</div>
|
||||
<div class="plan-price">$7</div>
|
||||
<div class="count"><b>5</b> private repositories</div>
|
||||
<div class="description">For smaller teams</div>
|
||||
<div class="plan" ng-repeat="plan in plans" ng-class="plan.stripeId">
|
||||
<div class="plan-title">{{ plan.title }}</div>
|
||||
<div class="plan-price">${{ plan.price/100 }}</div>
|
||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||
<div class="description">{{ plan.audience }}</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" ng-click="buyNow('micro')">Buy Now</button>
|
||||
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row plan-faq">
|
||||
<div class="col-md-6">
|
||||
<dl>
|
||||
<dt>Can I use Quay for free?</dt>
|
||||
<dd>Yes! We offer unlimited storage and serving of public repositories. We strongly believe in the open source community and will do what we can to help!</dd>
|
||||
<dt>What types of payment do you accept?</dt>
|
||||
<dd>Quay uses Stripe as our payment processor, so we can accept any of the payment options they offer, which are currently: Visa, MasterCard, American Express, JCB, Discover and Diners Club.</dd>
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<dl>
|
||||
<dt>Can I change my plan?</dt>
|
||||
<dd>Yes, you can change your plan at any time and your account will be pro-rated for the difference.</dd>
|
||||
<dt>Do you offer special plans for business or academic institutions?</dt>
|
||||
<dd>Please contact us at our <a href="mailto:support@quay.io">support email address</a> to discuss the details of your organization and inteded usage.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="plan focus">
|
||||
<div class="plan-title">Basic</div>
|
||||
<div class="plan-price">$12</div>
|
||||
<div class="count"><b>10</b> private repositories</div>
|
||||
<div class="description">For your basic team</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" ng-click="buyNow('small')">Buy Now</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<div class="plan-title">Medium</div>
|
||||
<div class="plan-price">$22</div>
|
||||
<div class="count"><b>20</b> private repositories</div>
|
||||
<div class="description">For medium-sized teams</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" ng-click="buyNow('medium')">Buy Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="signinModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Please Sign In</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Please sign into Quay in order to continue
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="signinModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Please Sign In</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Please sign into Quay in order to continue
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row" ng-hide="planLoading">
|
||||
<div class="col-md-4" ng-repeat='plan in plans'>
|
||||
<div class="col-md-3" ng-repeat='plan in plans'>
|
||||
<div class="panel" ng-class="{'panel-success': subscription.plan == plan.stripeId, 'panel-default': subscription.plan != plan.stripeId}">
|
||||
<div class="panel-heading">
|
||||
{{ plan.title }}
|
||||
|
@ -20,9 +20,16 @@
|
|||
<div class="panel-body panel-plan">
|
||||
<div class="plan-price">${{ plan.price / 100 }}</div>
|
||||
<div class="plan-description"><b>{{ plan.privateRepos }}</b> Private Repositories</div>
|
||||
<button class="btn btn-primary" ng-hide="subscription" ng-click="subscribe(plan.stripeId)">Subscribe</button>
|
||||
<button class="btn" ng-show="subscription && (subscription.plan != plan.stripeId)" ng-click="changeSubscription(plan.stripeId)">Change</button>
|
||||
<button class="btn btn-danger" ng-show="subscription.plan == plan.stripeId" ng-click="cancelSubscription()">Cancel</button>
|
||||
<div ng-switch='plan.stripeId'>
|
||||
<div ng-switch-when='free'>
|
||||
<button class="btn button-hidden">Hidden!</button>
|
||||
</div>
|
||||
<div ng-switch-default>
|
||||
<button class="btn btn-primary" ng-show="subscription.plan === 'free'" ng-click="subscribe(plan.stripeId)">Subscribe</button>
|
||||
<button class="btn btn-default" ng-hide="subscription.plan === 'free' || subscription.plan === plan.stripeId" ng-click="changeSubscription(plan.stripeId)">Change</button>
|
||||
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId" ng-click="cancelSubscription()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Reference in a new issue