satellite/console: make paywall optional

Add a config so that some percent of users require credit cards /
account balances
in order to create a project or have a promotional coupon applied

UI was updated to match needed paywall status

At this point we decided not to use a field to store if a user is in an
A/B
test, and instead just use math to see if they're in a test.  We decided
to use MD5 (because its in Postgres too) and User UUID for that math.

Change-Id: I0fcd80707dc29afc668632d078e1b5a7a24f3bb3
This commit is contained in:
Bill Thorp 2020-07-10 09:05:17 -04:00 committed by Kaloyan Raev
parent d30cb1ada2
commit b265b7f555
30 changed files with 518 additions and 40 deletions

View File

@ -83,7 +83,8 @@ func setupPayments(log *zap.Logger, db satellite.DB) (*stripecoinpayments.Servic
pc.CouponValue, pc.CouponValue,
pc.CouponDuration, pc.CouponDuration,
pc.CouponProjectLimit, pc.CouponProjectLimit,
pc.MinCoinPayment) pc.MinCoinPayment,
pc.PaywallProportion)
} }
// parseBillingPeriodFromString parses provided date string and returns corresponding time.Time. // parseBillingPeriodFromString parses provided date string and returns corresponding time.Time.

View File

@ -483,8 +483,9 @@ func (planet *Planet) newSatellites(count int, satelliteDatabases satellitedbtes
ConversionRatesCycleInterval: defaultInterval, ConversionRatesCycleInterval: defaultInterval,
ListingLimit: 100, ListingLimit: 100,
}, },
CouponDuration: 2, CouponDuration: 2,
CouponValue: 275, CouponValue: 275,
PaywallProportion: 1,
}, },
Repairer: repairer.Config{ Repairer: repairer.Config{
MaxRepair: 10, MaxRepair: 10,

View File

@ -134,7 +134,8 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB,
pc.CouponValue, pc.CouponValue,
pc.CouponDuration, pc.CouponDuration,
pc.CouponProjectLimit, pc.CouponProjectLimit,
pc.MinCoinPayment) pc.MinCoinPayment,
pc.PaywallProportion)
if err != nil { if err != nil {
return nil, errs.Combine(err, peer.Close()) return nil, errs.Combine(err, peer.Close())

View File

@ -538,7 +538,8 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
pc.CouponValue, pc.CouponValue,
pc.CouponDuration, pc.CouponDuration,
pc.CouponProjectLimit, pc.CouponProjectLimit,
pc.MinCoinPayment) pc.MinCoinPayment,
pc.PaywallProportion)
if err != nil { if err != nil {
return nil, errs.Combine(err, peer.Close()) return nil, errs.Combine(err, peer.Close())

View File

@ -15,6 +15,7 @@ import (
"github.com/zeebo/errs" "github.com/zeebo/errs"
"go.uber.org/zap" "go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/satellite/console" "storj.io/storj/satellite/console"
) )
@ -305,6 +306,34 @@ func (p *Payments) TokenDeposit(w http.ResponseWriter, r *http.Request) {
} }
} }
// PaywallEnabled returns is paywall enabled status.
func (p *Payments) PaywallEnabled(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
vars := mux.Vars(r)
reqID := vars["userId"]
if reqID == "" {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
userID, err := uuid.FromString(reqID)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
paywallEnabled := p.service.PaywallEnabled(userID)
err = json.NewEncoder(w).Encode(paywallEnabled)
if err != nil {
p.log.Error("failed to write json paywall enabled response", zap.Error(ErrPaymentsAPI.Wrap(err)))
}
}
// serveJSONError writes JSON error to response output stream. // serveJSONError writes JSON error to response output stream.
func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) { func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) {
if status == http.StatusInternalServerError { if status == http.StatusInternalServerError {

View File

@ -0,0 +1,37 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi_test
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"storj.io/common/testcontext"
"storj.io/common/uuid"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
)
// TestPaywallEnabled ensures that the Paywall A/B test config works.
func TestPaywallEnabled(t *testing.T) {
lowUUID := uuid.UUID{0}
highUUID := uuid.UUID{255}
testplanet.Run(t, testplanet.Config{
SatelliteCount: 3, Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
proportions := []float64{0, .5, 1}
config.Payments.PaywallProportion = proportions[index]
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
assert.False(t, planet.Satellites[0].API.Console.Service.PaywallEnabled(highUUID))
assert.False(t, planet.Satellites[0].API.Console.Service.PaywallEnabled(lowUUID))
assert.False(t, planet.Satellites[1].API.Console.Service.PaywallEnabled(highUUID))
assert.True(t, planet.Satellites[1].API.Console.Service.PaywallEnabled(lowUUID))
assert.True(t, planet.Satellites[2].API.Console.Service.PaywallEnabled(highUUID))
assert.True(t, planet.Satellites[2].API.Console.Service.PaywallEnabled(lowUUID))
})
}

View File

@ -90,7 +90,8 @@ func TestGrapqhlMutation(t *testing.T) {
pc.CouponValue, pc.CouponValue,
pc.CouponDuration, pc.CouponDuration,
pc.CouponProjectLimit, pc.CouponProjectLimit,
pc.MinCoinPayment) pc.MinCoinPayment,
pc.PaywallProportion)
require.NoError(t, err) require.NoError(t, err)
service, err := console.NewService( service, err := console.NewService(

View File

@ -74,7 +74,8 @@ func TestGraphqlQuery(t *testing.T) {
pc.CouponValue, pc.CouponValue,
pc.CouponDuration, pc.CouponDuration,
pc.CouponProjectLimit, pc.CouponProjectLimit,
pc.MinCoinPayment) pc.MinCoinPayment,
pc.PaywallProportion)
require.NoError(t, err) require.NoError(t, err)
service, err := console.NewService( service, err := console.NewService(

View File

@ -188,6 +188,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail
paymentsRouter.HandleFunc("/account", paymentController.SetupAccount).Methods(http.MethodPost) paymentsRouter.HandleFunc("/account", paymentController.SetupAccount).Methods(http.MethodPost)
paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet) paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet)
paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost) paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost)
paymentsRouter.HandleFunc("/paywall-enabled/{userId}", paymentController.PaywallEnabled).Methods(http.MethodGet)
if server.config.StaticDir != "" { if server.config.StaticDir != "" {
router.HandleFunc("/activation/", server.accountActivationHandler) router.HandleFunc("/activation/", server.accountActivationHandler)

View File

@ -174,9 +174,13 @@ func (paymentService PaymentsService) AddCreditCard(ctx context.Context, creditC
return Error.Wrap(err) return Error.Wrap(err)
} }
if !paymentService.service.accounts.PaywallEnabled(auth.User.ID) {
return nil
}
err = paymentService.AddPromotionalCoupon(ctx, auth.User.ID) err = paymentService.AddPromotionalCoupon(ctx, auth.User.ID)
if err != nil { if err != nil {
paymentService.service.log.Debug(fmt.Sprintf("could not add promotional coupon sof user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err))) paymentService.service.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err)))
} }
return nil return nil
@ -387,12 +391,14 @@ func (paymentService PaymentsService) PopulatePromotionalCoupons(ctx context.Con
func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) { func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx, userID)(&err) defer mon.Task()(&ctx, userID)(&err)
cards, err := paymentService.ListCreditCards(ctx) if paymentService.service.accounts.PaywallEnabled(userID) {
if err != nil { cards, err := paymentService.ListCreditCards(ctx)
return err if err != nil {
} return err
if len(cards) == 0 { }
return errs.New("user don't have a payment method") if len(cards) == 0 {
return errs.New("user don't have a payment method")
}
} }
return paymentService.service.accounts.Coupons().AddPromotionalCoupon(ctx, userID) return paymentService.service.accounts.Coupons().AddPromotionalCoupon(ctx, userID)
@ -610,6 +616,15 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (
return Error.Wrap(err) return Error.Wrap(err)
} }
if s.accounts.PaywallEnabled(user.ID) {
return nil
}
err = s.accounts.Coupons().AddPromotionalCoupon(ctx, user.ID)
if err != nil {
s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", user.ID.String()), zap.Error(Error.Wrap(err)))
}
return nil return nil
} }
@ -881,22 +896,24 @@ func (s *Service) CreateProject(ctx context.Context, projectInfo ProjectInfo) (p
return nil, ErrProjLimit.Wrap(err) return nil, ErrProjLimit.Wrap(err)
} }
cards, err := s.accounts.CreditCards().List(ctx, auth.User.ID) if s.accounts.PaywallEnabled(auth.User.ID) {
if err != nil { cards, err := s.accounts.CreditCards().List(ctx, auth.User.ID)
s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err))) if err != nil {
return nil, Error.Wrap(err) s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err)))
} return nil, Error.Wrap(err)
}
balance, err := s.accounts.Balance(ctx, auth.User.ID) balance, err := s.accounts.Balance(ctx, auth.User.ID)
if err != nil { if err != nil {
s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err))) s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err)))
return nil, Error.Wrap(err) return nil, Error.Wrap(err)
} }
if len(cards) == 0 && balance.Coins < s.minCoinPayment { if len(cards) == 0 && balance.Coins < s.minCoinPayment {
err = errs.New("no valid payment methods found") err = errs.New("no valid payment methods found")
s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err))) s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(Error.Wrap(err)))
return nil, Error.Wrap(err) return nil, Error.Wrap(err)
}
} }
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error { err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
@ -1513,3 +1530,9 @@ func findMembershipByProjectID(memberships []ProjectMember, projectID uuid.UUID)
} }
return ProjectMember{}, false return ProjectMember{}, false
} }
// PaywallEnabled returns a true if a credit card or account
// balance is required to create projects.
func (s *Service) PaywallEnabled(userID uuid.UUID) bool {
return s.accounts.PaywallEnabled(userID)
}

View File

@ -475,7 +475,8 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
pc.CouponValue, pc.CouponValue,
pc.CouponDuration, pc.CouponDuration,
pc.CouponProjectLimit, pc.CouponProjectLimit,
pc.MinCoinPayment) pc.MinCoinPayment,
pc.PaywallProportion)
if err != nil { if err != nil {
return nil, errs.Combine(err, peer.Close()) return nil, errs.Combine(err, peer.Close())

View File

@ -46,4 +46,8 @@ type Accounts interface {
// Coupons exposes all needed functionality to manage coupons. // Coupons exposes all needed functionality to manage coupons.
Coupons() Coupons Coupons() Coupons
// PaywallEnabled returns a true if a credit card or account
// balance is required to create projects
PaywallEnabled(uuid.UUID) bool
} }

View File

@ -24,4 +24,5 @@ type Config struct {
NodeRepairBandwidthPrice int64 `help:"price node receive for storing TB of repair in cents" default:"1000"` NodeRepairBandwidthPrice int64 `help:"price node receive for storing TB of repair in cents" default:"1000"`
NodeAuditBandwidthPrice int64 `help:"price node receive for storing TB of audit in cents" default:"1000"` NodeAuditBandwidthPrice int64 `help:"price node receive for storing TB of audit in cents" default:"1000"`
NodeDiskSpacePrice int64 `help:"price node receive for storing disk space in cents/TB" default:"150"` NodeDiskSpacePrice int64 `help:"price node receive for storing disk space in cents/TB" default:"150"`
PaywallProportion float64 `help:"proportion of users which require a balance to create projects [0-1]" default:"1.0"`
} }

View File

@ -194,3 +194,14 @@ func (accounts *accounts) Credits() payments.Credits {
return &credits{service: accounts.service} return &credits{service: accounts.service}
} }
// PaywallEnabled returns a true if a credit card or account
// balance is required to create projects.
func (accounts *accounts) PaywallEnabled(userID uuid.UUID) bool {
return BytesAreWithinProportion(userID, accounts.service.PaywallProportion)
}
//BytesAreWithinProportion returns true if first byte is less than the normalized proportion [0..1].
func BytesAreWithinProportion(uuidBytes [16]byte, proportion float64) bool {
return int(uuidBytes[0]) < int(proportion*256)
}

View File

@ -0,0 +1,39 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
package stripecoinpayments_test
import (
"testing"
"github.com/stretchr/testify/assert"
"storj.io/common/uuid"
"storj.io/storj/satellite/payments/stripecoinpayments"
)
func TestBytesAreWithinProportion(t *testing.T) {
f := stripecoinpayments.BytesAreWithinProportion
assert.False(t, f(uuid.UUID{0}, 0.0))
assert.False(t, f(uuid.UUID{255}, 0.25))
assert.False(t, f(uuid.UUID{192}, 0.25))
assert.False(t, f(uuid.UUID{128}, 0.25))
assert.False(t, f(uuid.UUID{64}, 0.25))
assert.True(t, f(uuid.UUID{63, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, 0.25))
assert.True(t, f(uuid.UUID{32}, 0.25))
assert.False(t, f(uuid.UUID{129}, 0.5))
assert.False(t, f(uuid.UUID{128}, 0.5))
assert.True(t, f(uuid.UUID{127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, 0.5))
assert.True(t, f(uuid.UUID{127}, 0.5))
assert.False(t, f(uuid.UUID{255}, 0.75))
assert.False(t, f(uuid.UUID{192}, 0.75))
assert.True(t, f(uuid.UUID{191, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, 0.75))
assert.True(t, f(uuid.UUID{128}, 0.75))
assert.True(t, f(uuid.UUID{64}, 0.75))
assert.True(t, f(uuid.UUID{32}, 0.75))
assert.True(t, f(uuid.UUID{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, 1.0))
}

View File

@ -82,12 +82,13 @@ type Service struct {
rates coinpayments.CurrencyRateInfos rates coinpayments.CurrencyRateInfos
ratesErr error ratesErr error
listingLimit int listingLimit int
nowFn func() time.Time nowFn func() time.Time
PaywallProportion float64
} }
// NewService creates a Service instance. // NewService creates a Service instance.
func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, projectsDB console.Projects, usageDB accounting.ProjectAccounting, storageTBPrice, egressTBPrice, objectPrice string, bonusRate, couponValue, couponDuration int64, couponProjectLimit memory.Size, minCoinPayment int64) (*Service, error) { func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, projectsDB console.Projects, usageDB accounting.ProjectAccounting, storageTBPrice, egressTBPrice, objectPrice string, bonusRate, couponValue, couponDuration int64, couponProjectLimit memory.Size, minCoinPayment int64, paywallProportion float64) (*Service, error) {
coinPaymentsClient := coinpayments.NewClient( coinPaymentsClient := coinpayments.NewClient(
coinpayments.Credentials{ coinpayments.Credentials{
@ -132,6 +133,7 @@ func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB
AutoAdvance: config.AutoAdvance, AutoAdvance: config.AutoAdvance,
listingLimit: config.ListingLimit, listingLimit: config.ListingLimit,
nowFn: time.Now, nowFn: time.Now,
PaywallProportion: paywallProportion,
}, nil }, nil
} }
@ -210,6 +212,10 @@ func (service *Service) updateTransactions(ctx context.Context, ids TransactionA
userID := ids[id] userID := ids[id]
if !service.Accounts().PaywallEnabled(userID) {
continue
}
rate, err := service.db.Transactions().GetLockedRate(ctx, id) rate, err := service.db.Transactions().GetLockedRate(ctx, id)
if err != nil { if err != nil {
service.log.Error(fmt.Sprintf("could not add promotional coupon for user %s", userID.String()), zap.Error(err)) service.log.Error(fmt.Sprintf("could not add promotional coupon for user %s", userID.String()), zap.Error(err))

View File

@ -529,6 +529,9 @@ identity.key-path: /root/.local/share/storj/identity/satellite/identity.key
# price user should pay for each object stored in network per month # price user should pay for each object stored in network per month
# payments.object-price: "0.0000022" # payments.object-price: "0.0000022"
# proportion of users which require a balance to create projects [0-1]
# payments.paywall-proportion: 1
# payments provider to use # payments provider to use
# payments.provider: "" # payments.provider: ""

View File

@ -253,4 +253,25 @@ export class PaymentsHttpApi implements PaymentsApi {
return new TokenDeposit(result.amount, result.address, result.link); return new TokenDeposit(result.amount, result.address, result.link);
} }
/**
* Indicates if paywall is enabled.
*
* @param userId
* @throws Error
*/
public async getPaywallEnabledStatus(userId: string): Promise<boolean> {
const path = `${this.ROOT_PATH}/paywall-enabled/${userId}`;
const response = await this.client.get(path);
if (!response.ok) {
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('can not process coin payment');
}
return await response.json();
}
} }

View File

@ -3,7 +3,7 @@
<template> <template>
<div class="tour-area"> <div class="tour-area">
<div class="tour-area__info-bar" v-show="isInfoBarVisible"> <div class="tour-area__info-bar" v-show="isInfoBarVisible && isPaywallEnabled">
<div class="tour-area__info-bar__message"> <div class="tour-area__info-bar__message">
<b class="tour-area__info-bar__message__bold">Try Tardigrade with 50 GB Free after adding a payment method.</b> <b class="tour-area__info-bar__message__bold">Try Tardigrade with 50 GB Free after adding a payment method.</b>
<p class="tour-area__info-bar__message__regular"> Cancel before your credit runs out and youll never be billed.</p> <p class="tour-area__info-bar__message__regular"> Cancel before your credit runs out and youll never be billed.</p>
@ -12,15 +12,20 @@
</div> </div>
<div class="tour-area__content"> <div class="tour-area__content">
<ProgressBar <ProgressBar
:is-paywall-enabled="isPaywallEnabled"
:is-add-payment-step="isAddPaymentState" :is-add-payment-step="isAddPaymentState"
:is-create-project-step="isCreateProjectState" :is-create-project-step="isCreateProjectState"
:is-create-api-key-step="isCreatApiKeyState" :is-create-api-key-step="isCreatApiKeyState"
:is-upload-data-step="isUploadDataState" :is-upload-data-step="isUploadDataState"
/> />
<OverviewStep <OverviewStep
v-if="isDefaultState" v-if="isDefaultState && isPaywallEnabled"
@setAddPaymentState="setAddPaymentState" @setAddPaymentState="setAddPaymentState"
/> />
<OverviewStepNoPaywall
v-if="isDefaultState && !isPaywallEnabled"
@setCreateProjectState="setCreateProjectState"
/>
<AddPaymentStep <AddPaymentStep
v-if="isAddPaymentState" v-if="isAddPaymentState"
@setProjectState="setCreateProjectState" @setProjectState="setCreateProjectState"
@ -52,6 +57,7 @@ import AddPaymentStep from '@/components/onboardingTour/steps/AddPaymentStep.vue
import CreateApiKeyStep from '@/components/onboardingTour/steps/CreateApiKeyStep.vue'; import CreateApiKeyStep from '@/components/onboardingTour/steps/CreateApiKeyStep.vue';
import CreateProjectStep from '@/components/onboardingTour/steps/CreateProjectStep.vue'; import CreateProjectStep from '@/components/onboardingTour/steps/CreateProjectStep.vue';
import OverviewStep from '@/components/onboardingTour/steps/OverviewStep.vue'; import OverviewStep from '@/components/onboardingTour/steps/OverviewStep.vue';
import OverviewStepNoPaywall from '@/components/onboardingTour/steps/OverviewStepNoPaywall.vue';
import UploadDataStep from '@/components/onboardingTour/steps/UploadDataStep.vue'; import UploadDataStep from '@/components/onboardingTour/steps/UploadDataStep.vue';
import CheckedImage from '@/../static/images/common/checked.svg'; import CheckedImage from '@/../static/images/common/checked.svg';
@ -59,9 +65,11 @@ import CloseImage from '@/../static/images/onboardingTour/close.svg';
import { RouteConfig } from '@/router'; import { RouteConfig } from '@/router';
import { TourState } from '@/utils/constants/onboardingTourEnums'; import { TourState } from '@/utils/constants/onboardingTourEnums';
import { MetaUtils } from '@/utils/meta';
@Component({ @Component({
components: { components: {
OverviewStepNoPaywall,
UploadDataStep, UploadDataStep,
CreateApiKeyStep, CreateApiKeyStep,
CreateProjectStep, CreateProjectStep,
@ -111,6 +119,13 @@ export default class OnboardingTourArea extends Vue {
} }
} }
/**
* Indicates if paywall is enabled.
*/
public get isPaywallEnabled(): boolean {
return this.$store.state.paymentsModule.paywallEnabled;
}
/** /**
* Indicates if area is in default state. * Indicates if area is in default state.
*/ */

View File

@ -5,12 +5,14 @@
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar-container__progress-area"> <div class="progress-bar-container__progress-area">
<div <div
v-if="isPaywallEnabled"
class="progress-bar-container__progress-area__circle" class="progress-bar-container__progress-area__circle"
:class="{ 'completed-step': isAddPaymentStep || isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }" :class="{ 'completed-step': isAddPaymentStep || isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }"
> >
<CheckedImage/> <CheckedImage/>
</div> </div>
<div <div
v-if="isPaywallEnabled"
class="progress-bar-container__progress-area__bar" class="progress-bar-container__progress-area__bar"
:class="{ 'completed-step': isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }" :class="{ 'completed-step': isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }"
/> />
@ -41,8 +43,9 @@
<CheckedImage/> <CheckedImage/>
</div> </div>
</div> </div>
<div class="progress-bar-container__titles-area"> <div class="progress-bar-container__titles-area" :class="{ 'titles-area-no-paywall': !isPaywallEnabled }">
<span <span
v-if="isPaywallEnabled"
class="progress-bar-container__titles-area__title" class="progress-bar-container__titles-area__title"
:class="{ 'completed-font-color': isAddPaymentStep || isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }" :class="{ 'completed-font-color': isAddPaymentStep || isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }"
> >
@ -50,7 +53,7 @@
</span> </span>
<span <span
class="progress-bar-container__titles-area__title name-your-project-title" class="progress-bar-container__titles-area__title name-your-project-title"
:class="{ 'completed-font-color': isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep }" :class="{ 'completed-font-color': isCreateProjectStep || isCreateApiKeyStep || isUploadDataStep, 'title-no-paywall': !isPaywallEnabled }"
> >
Name Your Project Name Your Project
</span> </span>
@ -82,6 +85,8 @@ import CheckedImage from '@/../static/images/common/checked.svg';
}) })
export default class ProgressBar extends Vue { export default class ProgressBar extends Vue {
@Prop({ default: false })
public readonly isPaywallEnabled: boolean;
@Prop({ default: false }) @Prop({ default: false })
public readonly isAddPaymentStep: boolean; public readonly isAddPaymentStep: boolean;
@Prop({ default: false }) @Prop({ default: false })
@ -153,6 +158,14 @@ export default class ProgressBar extends Vue {
color: #2683ff; color: #2683ff;
} }
.titles-area-no-paywall {
padding: 0 188px 0 178px;
}
.title-no-paywall {
padding: 0;
}
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.progress-bar-container { .progress-bar-container {
@ -166,5 +179,9 @@ export default class ProgressBar extends Vue {
padding: 0 128px 0 128px; padding: 0 128px 0 128px;
} }
} }
.titles-area-no-paywall {
padding: 0 128px 0 118px;
}
} }
</style> </style>

View File

@ -0,0 +1,34 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
<template src="./overviewStepNoPaywall.html"></template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import VButton from '@/components/common/VButton.vue';
import FirstStepIcon from '@/../static/images/common/one.svg';
import ThirdStepIcon from '@/../static/images/common/three.svg';
import SecondStepIcon from '@/../static/images/common/two.svg';
@Component({
components: {
VButton,
FirstStepIcon,
ThirdStepIcon,
SecondStepIcon,
},
})
export default class OverviewStepNoPaywall extends Vue {
/**
* Holds button click logic.
* Sets tour state to adding project state.
*/
public onClick(): void {
this.$emit('setCreateProjectState');
}
}
</script>
<style scoped lang="scss" src="./overviewStepNoPaywall.scss"></style>

View File

@ -0,0 +1,43 @@
<!--Copyright (C) 2020 Storj Labs, Inc.-->
<!--See LICENSE for copying information.-->
<div class="overview-area">
<h1 class="overview-area__title">Welcome to Storj</h1>
<p class="overview-area__sub-title">
Youre just a few steps away from uploading your first object to the 100% secure, decentralized cloud. Simply
add a payment method any time before your credit runs out to keep using Storj.
</p>
<div class="overview-area__steps-area">
<div class="overview-area__steps-area__step">
<FirstStepIcon class="overview-area__steps-area__step__icon"/>
<img class="overview-step-image" src="@/../static/images/onboardingTour/project.jpg" alt="project image">
<h2 class="overview-area__steps-area__step__title">Name Your Project</h2>
<span class="overview-area__steps-area__step__subtitle">
Projects are where buckets are created for storing data.
</span>
</div>
<div class="overview-area__steps-area__step second-step">
<SecondStepIcon class="overview-area__steps-area__step__icon"/>
<img class="overview-step-image" src="@/../static/images/onboardingTour/api-key.jpg" alt="api keys image">
<h2 class="overview-area__steps-area__step__title">Create an API Key</h2>
<span class="overview-area__steps-area__step__subtitle">
Generate access to your project to upload data.
</span>
</div>
<div class="overview-area__steps-area__step">
<ThirdStepIcon class="overview-area__steps-area__step__icon"/>
<img class="overview-step-image" src="@/../static/images/onboardingTour/uplink.jpg" alt="uplink image">
<h2 class="overview-area__steps-area__step__title">Upload Data</h2>
<span class="overview-area__steps-area__step__subtitle">
Store your data on the secure, decentralized cloud.
</span>
</div>
</div>
<VButton
class="get-started-button"
label="Get Started"
width="251px"
height="56px"
:on-press="onClick"
/>
</div>

View File

@ -0,0 +1,107 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
p,
h1,
h2 {
margin: 0;
}
.overview-area {
width: auto;
padding: 75px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
font-family: 'font_regular', sans-serif;
background-color: #fff;
border-radius: 6px;
margin-top: 25px;
&__title {
font-family: 'font_bold', sans-serif;
font-size: 32px;
line-height: 39px;
color: #1b2533;
margin-bottom: 25px;
}
&__sub-title {
font-size: 16px;
line-height: 26px;
color: #354049;
margin-bottom: 60px;
text-align: center;
max-width: 815px;
}
&__steps-area {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 50px;
&__step {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
position: relative;
max-width: 190px;
width: 190px;
&__icon {
position: absolute;
top: -15px;
left: 80px;
}
&__title {
font-size: 16px;
line-height: 26px;
text-align: center;
color: #354049;
margin: 15px 0 5px 0;
}
&__subtitle {
font-family: 'font_regular', sans-serif;
font-size: 14px;
line-height: 17px;
text-align: center;
color: #61666b;
word-break: break-word;
}
}
}
}
.second-step {
margin: 0 50px 0 50px;
}
@media screen and (max-width: 1450px) {
.overview-area {
padding: 75px 30px;
}
.second-step {
margin: 0 20px 0 20px;
}
}
@media screen and (max-width: 900px) {
.overview-area {
&__steps-area {
flex-direction: column;
&__step {
margin: 0 0 30px 0;
}
}
}
}

View File

@ -26,6 +26,7 @@ export const PAYMENTS_MUTATIONS = {
SET_CURRENT_ROLLUP_PRICE: 'SET_CURRENT_ROLLUP_PRICE', SET_CURRENT_ROLLUP_PRICE: 'SET_CURRENT_ROLLUP_PRICE',
SET_PREVIOUS_ROLLUP_PRICE: 'SET_PREVIOUS_ROLLUP_PRICE', SET_PREVIOUS_ROLLUP_PRICE: 'SET_PREVIOUS_ROLLUP_PRICE',
SET_PRICE_SUMMARY: 'SET_PRICE_SUMMARY', SET_PRICE_SUMMARY: 'SET_PRICE_SUMMARY',
SET_PAYWALL_ENABLED_STATUS: 'SET_PAYWALL_ENABLED_STATUS',
}; };
export const PAYMENTS_ACTIONS = { export const PAYMENTS_ACTIONS = {
@ -43,6 +44,7 @@ export const PAYMENTS_ACTIONS = {
GET_PROJECT_USAGE_AND_CHARGES: 'getProjectUsageAndCharges', GET_PROJECT_USAGE_AND_CHARGES: 'getProjectUsageAndCharges',
GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP: 'getProjectUsageAndChargesCurrentRollup', GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP: 'getProjectUsageAndChargesCurrentRollup',
GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP: 'getProjectUsageAndChargesPreviousRollup', GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP: 'getProjectUsageAndChargesPreviousRollup',
GET_PAYWALL_ENABLED_STATUS: 'getPaywallEnabledStatus',
}; };
const { const {
@ -55,6 +57,7 @@ const {
SET_PAYMENTS_HISTORY, SET_PAYMENTS_HISTORY,
SET_PROJECT_USAGE_AND_CHARGES, SET_PROJECT_USAGE_AND_CHARGES,
SET_PRICE_SUMMARY, SET_PRICE_SUMMARY,
SET_PAYWALL_ENABLED_STATUS,
} = PAYMENTS_MUTATIONS; } = PAYMENTS_MUTATIONS;
const { const {
@ -71,6 +74,7 @@ const {
MAKE_TOKEN_DEPOSIT, MAKE_TOKEN_DEPOSIT,
GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP, GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP,
GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP, GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP,
GET_PAYWALL_ENABLED_STATUS,
} = PAYMENTS_ACTIONS; } = PAYMENTS_ACTIONS;
export class PaymentsState { export class PaymentsState {
@ -84,6 +88,7 @@ export class PaymentsState {
public priceSummary: number = 0; public priceSummary: number = 0;
public startDate: Date = new Date(); public startDate: Date = new Date();
public endDate: Date = new Date(); public endDate: Date = new Date();
public paywallEnabled: boolean = true;
} }
/** /**
@ -148,6 +153,9 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState>
state.priceSummary = usageItemSummaries.reduce((accumulator, current) => accumulator + current); state.priceSummary = usageItemSummaries.reduce((accumulator, current) => accumulator + current);
}, },
[SET_PAYWALL_ENABLED_STATUS](state: PaymentsState, paywallEnabledStatus: boolean): void {
state.paywallEnabled = paywallEnabledStatus;
},
[CLEAR](state: PaymentsState) { [CLEAR](state: PaymentsState) {
state.balance = new AccountBalance(); state.balance = new AccountBalance();
state.paymentsHistory = []; state.paymentsHistory = [];
@ -156,6 +164,7 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState>
state.creditCards = []; state.creditCards = [];
state.startDate = new Date(); state.startDate = new Date();
state.endDate = new Date(); state.endDate = new Date();
state.paywallEnabled = true;
}, },
}, },
actions: { actions: {
@ -228,6 +237,11 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState>
commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges); commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges); commit(SET_PRICE_SUMMARY, usageAndCharges);
}, },
[GET_PAYWALL_ENABLED_STATUS]: async function({commit, rootGetters}: any): Promise<void> {
const paywallEnabledStatus: boolean = await api.getPaywallEnabledStatus(rootGetters.user.id);
commit(SET_PAYWALL_ENABLED_STATUS, paywallEnabledStatus);
},
}, },
getters: { getters: {
canUserCreateFirstProject: (state: PaymentsState): boolean => { canUserCreateFirstProject: (state: PaymentsState): boolean => {

View File

@ -69,6 +69,14 @@ export interface PaymentsApi {
* @throws Error * @throws Error
*/ */
makeTokenDeposit(amount: number): Promise<TokenDeposit>; makeTokenDeposit(amount: number): Promise<TokenDeposit>;
/**
* Indicates if paywall is enabled.
*
* @param userId
* @throws Error
*/
getPaywallEnabledStatus(userId: string): Promise<boolean>;
} }
export class AccountBalance { export class AccountBalance {

View File

@ -58,6 +58,7 @@ import { AppState } from '@/utils/constants/appStateEnum';
import { ProjectOwning } from '@/utils/projectOwning'; import { ProjectOwning } from '@/utils/projectOwning';
const { const {
GET_PAYWALL_ENABLED_STATUS,
SETUP_ACCOUNT, SETUP_ACCOUNT,
GET_BALANCE, GET_BALANCE,
GET_CREDIT_CARDS, GET_CREDIT_CARDS,
@ -100,6 +101,12 @@ export default class DashboardArea extends Vue {
return; return;
} }
try {
await this.$store.dispatch(GET_PAYWALL_ENABLED_STATUS);
} catch (error) {
await this.$notify.error(`Unable to get paywall enabled status. ${error.message}`);
}
try { try {
await this.$store.dispatch(SETUP_ACCOUNT); await this.$store.dispatch(SETUP_ACCOUNT);
} catch (error) { } catch (error) {

View File

@ -49,4 +49,8 @@ export class PaymentsMock implements PaymentsApi {
makeTokenDeposit(amount: number): Promise<TokenDeposit> { makeTokenDeposit(amount: number): Promise<TokenDeposit> {
return Promise.resolve(new TokenDeposit(amount, 'testAddress', 'testLink')); return Promise.resolve(new TokenDeposit(amount, 'testAddress', 'testLink'));
} }
getPaywallEnabledStatus(userId: string): Promise<boolean> {
throw new Error('Method not implemented');
}
} }

View File

@ -6,8 +6,22 @@ import ProgressBar from '@/components/onboardingTour/ProgressBar.vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
describe('ProgressBar.vue', () => { describe('ProgressBar.vue', () => {
it('renders correctly', (): void => { it('renders correctly if paywall is enabled', (): void => {
const wrapper = mount(ProgressBar); const wrapper = mount(ProgressBar, {
propsData: {
isPaywallEnabled: true,
},
});
expect(wrapper).toMatchSnapshot();
});
it('renders correctly if paywall is disabled', (): void => {
const wrapper = mount(ProgressBar, {
propsData: {
isPaywallEnabled: false,
},
});
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
@ -16,6 +30,7 @@ describe('ProgressBar.vue', () => {
const wrapper = mount(ProgressBar, { const wrapper = mount(ProgressBar, {
propsData: { propsData: {
isAddPaymentStep: true, isAddPaymentStep: true,
isPaywallEnabled: true,
}, },
}); });
@ -27,6 +42,7 @@ describe('ProgressBar.vue', () => {
const wrapper = mount(ProgressBar, { const wrapper = mount(ProgressBar, {
propsData: { propsData: {
isCreateProjectStep: true, isCreateProjectStep: true,
isPaywallEnabled: true,
}, },
}); });
@ -38,6 +54,7 @@ describe('ProgressBar.vue', () => {
const wrapper = mount(ProgressBar, { const wrapper = mount(ProgressBar, {
propsData: { propsData: {
isCreateApiKeyStep: true, isCreateApiKeyStep: true,
isPaywallEnabled: true,
}, },
}); });
@ -49,6 +66,7 @@ describe('ProgressBar.vue', () => {
const wrapper = mount(ProgressBar, { const wrapper = mount(ProgressBar, {
propsData: { propsData: {
isUploadDataStep: true, isUploadDataStep: true,
isPaywallEnabled: true,
}, },
}); });

View File

@ -1,6 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProgressBar.vue renders correctly 1`] = ` exports[`ProgressBar.vue renders correctly if paywall is disabled 1`] = `
<div class="progress-bar-container">
<div class="progress-bar-container__progress-area">
<!---->
<!---->
<div class="progress-bar-container__progress-area__circle"><svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.61854 0.302314C8.02258 -0.100771 8.67764 -0.100771 9.08163 0.302314C9.48569 0.705397 9.48569 1.35893 9.08163 1.76202L4.20463 6.62768C3.8006 7.03077 3.14555 7.03077 2.74152 6.62768L0.303018 4.19485C-0.101006 3.79177 -0.101006 3.13823 0.303018 2.73515C0.707044 2.33206 1.3621 2.33206 1.76612 2.73515L3.47307 4.43813L7.61854 0.302314Z" fill="white"></path>
</svg></div>
<div class="progress-bar-container__progress-area__bar"></div>
<div class="progress-bar-container__progress-area__circle"><svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.61854 0.302314C8.02258 -0.100771 8.67764 -0.100771 9.08163 0.302314C9.48569 0.705397 9.48569 1.35893 9.08163 1.76202L4.20463 6.62768C3.8006 7.03077 3.14555 7.03077 2.74152 6.62768L0.303018 4.19485C-0.101006 3.79177 -0.101006 3.13823 0.303018 2.73515C0.707044 2.33206 1.3621 2.33206 1.76612 2.73515L3.47307 4.43813L7.61854 0.302314Z" fill="white"></path>
</svg></div>
<div class="progress-bar-container__progress-area__bar"></div>
<div class="progress-bar-container__progress-area__circle"><svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.61854 0.302314C8.02258 -0.100771 8.67764 -0.100771 9.08163 0.302314C9.48569 0.705397 9.48569 1.35893 9.08163 1.76202L4.20463 6.62768C3.8006 7.03077 3.14555 7.03077 2.74152 6.62768L0.303018 4.19485C-0.101006 3.79177 -0.101006 3.13823 0.303018 2.73515C0.707044 2.33206 1.3621 2.33206 1.76612 2.73515L3.47307 4.43813L7.61854 0.302314Z" fill="white"></path>
</svg></div>
</div>
<div class="progress-bar-container__titles-area titles-area-no-paywall">
<!----> <span class="progress-bar-container__titles-area__title name-your-project-title title-no-paywall">
Name Your Project
</span> <span class="progress-bar-container__titles-area__title api-key-title">
Create an API Key
</span> <span class="progress-bar-container__titles-area__title">
Upload Data
</span></div>
</div>
`;
exports[`ProgressBar.vue renders correctly if paywall is enabled 1`] = `
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar-container__progress-area"> <div class="progress-bar-container__progress-area">
<div class="progress-bar-container__progress-area__circle"><svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg"> <div class="progress-bar-container__progress-area__circle"><svg width="10" height="7" viewBox="0 0 10 7" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@ -9,13 +9,14 @@ exports[`OnboardingTourArea.vue renders correctly 1`] = `
<closeimage-stub class="tour-area__info-bar__close-img"></closeimage-stub> <closeimage-stub class="tour-area__info-bar__close-img"></closeimage-stub>
</div> </div>
<div class="tour-area__content"> <div class="tour-area__content">
<progressbar-stub></progressbar-stub> <progressbar-stub ispaywallenabled="true"></progressbar-stub>
<overviewstep-stub></overviewstep-stub> <overviewstep-stub></overviewstep-stub>
<!----> <!---->
<!----> <!---->
<!----> <!---->
<!----> <!---->
<!----> <!---->
<!---->
</div> </div>
</div> </div>
`; `;