satellite/rewards: nicer offers handling (#2390)

* organize offers

* revert changes to go.mod and go.sum

* change OfferStatus enums back to original

* revert modified auto-gen files

* don't render empty row if offers is empty

* change return val of ListAll to Offers

* fix build

* add method to check for empty offer when rendering template

* fix typo

* fix lint and typos

* lean out IsEmpty

* dont use named return vals

* better clarify offer statuses

* change back order of setting offer.Status

* lint

* satellite/marketingweb: allow disabling rewards (#2392)

* implement handler for stop offer endpoint

* use proper text and fix data-target for free-credit stop modal
This commit is contained in:
Faris Huskovic 2019-07-10 13:12:40 -04:00 committed by GitHub
parent 94eeb58b45
commit 0d294103e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 200 additions and 118 deletions

View File

@ -9,6 +9,7 @@ import (
"net"
"net/http"
"path/filepath"
"strconv"
"github.com/gorilla/mux"
"github.com/zeebo/errs"
@ -43,30 +44,6 @@ type Server struct {
}
}
// offerSet provides a separation of marketing offers by type.
type offerSet struct {
ReferralOffers rewards.Offers
FreeCredits rewards.Offers
}
// organizeOffers organizes offers by type.
func organizeOffers(offers []rewards.Offer) offerSet {
var os offerSet
for _, offer := range offers {
switch offer.Type {
case rewards.FreeCredit:
os.FreeCredits.Set = append(os.FreeCredits.Set, offer)
case rewards.Referral:
os.ReferralOffers.Set = append(os.ReferralOffers.Set, offer)
default:
continue
}
}
return os
}
// commonPages returns templates that are required for all routes.
func (s *Server) commonPages() []string {
return []string{
@ -93,6 +70,7 @@ func NewServer(logger *zap.Logger, config Config, db rewards.DB, listener net.Li
mux.HandleFunc("/", s.GetOffers)
mux.PathPrefix("/static/").Handler(fs)
mux.HandleFunc("/create/{offer_type}", s.CreateOffer)
mux.HandleFunc("/stop/{offer_id}", s.StopOffer)
}
s.server.Handler = mux
@ -119,7 +97,7 @@ func (s *Server) GetOffers(w http.ResponseWriter, req *http.Request) {
return
}
if err := s.templates.home.ExecuteTemplate(w, "base", organizeOffers(offers)); err != nil {
if err := s.templates.home.ExecuteTemplate(w, "base", offers.OrganizeOffersByType()); err != nil {
s.log.Error("failed to execute template", zap.Error(err))
s.serveInternalError(w, req, err)
}
@ -133,6 +111,8 @@ func (s *Server) parseTemplates() (err error) {
filepath.Join(s.templateDir, "referral-offers-modal.html"),
filepath.Join(s.templateDir, "free-offers.html"),
filepath.Join(s.templateDir, "free-offers-modal.html"),
filepath.Join(s.templateDir, "stop-free-credit.html"),
filepath.Join(s.templateDir, "stop-referral-offer.html"),
)
pageNotFoundFiles := append(s.commonPages(),
@ -147,7 +127,9 @@ func (s *Server) parseTemplates() (err error) {
filepath.Join(s.templateDir, "err.html"),
)
s.templates.home, err = template.New("landingPage").ParseFiles(homeFiles...)
s.templates.home, err = template.New("landingPage").Funcs(template.FuncMap{
"isEmpty": rewards.Offer.IsEmpty,
}).ParseFiles(homeFiles...)
if err != nil {
return Error.Wrap(err)
}
@ -202,6 +184,24 @@ func (s *Server) CreateOffer(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/", http.StatusSeeOther)
}
// StopOffer expires the current offer and replaces it with the default offer.
func (s *Server) StopOffer(w http.ResponseWriter, req *http.Request) {
offerID, err := strconv.Atoi(mux.Vars(req)["offer_id"])
if err != nil {
s.log.Error("failed to parse offer id", zap.Error(err))
s.serveBadRequest(w, req, err)
return
}
if err := s.db.Finish(req.Context(), offerID); err != nil {
s.log.Error("failed to stop offer", zap.Error(err))
s.serveInternalError(w, req, err)
return
}
http.Redirect(w, req, "/", http.StatusSeeOther)
}
// serveNotFound handles 404 errors and defaults to 500 if template parsing fails.
func (s *Server) serveNotFound(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNotFound)

View File

@ -6,6 +6,7 @@ package marketingweb_test
import (
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/require"
@ -20,7 +21,7 @@ type CreateRequest struct {
Values url.Values
}
func TestCreateOffer(t *testing.T) {
func TestCreateAndStopOffers(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
@ -54,8 +55,10 @@ func TestCreateOffer(t *testing.T) {
addr := planet.Satellites[0].Marketing.Listener.Addr()
var group errgroup.Group
for _, offer := range requests {
for index, offer := range requests {
o := offer
id := strconv.Itoa(index + 1)
group.Go(func() error {
baseURL := "http://" + addr.String()
@ -69,6 +72,11 @@ func TestCreateOffer(t *testing.T) {
return err
}
_, err = http.Post(baseURL+"/stop/"+id, "application/x-www-form-urlencoded", nil)
if err != nil {
return err
}
return nil
})
}

View File

@ -12,7 +12,7 @@ import (
// DB holds information about offer
type DB interface {
ListAll(ctx context.Context) ([]Offer, error)
ListAll(ctx context.Context) (Offers, error)
GetCurrentByType(ctx context.Context, offerType OfferType) (*Offer, error)
Create(ctx context.Context, offer *NewOffer) (*Offer, error)
Redeem(ctx context.Context, offerID int, isDefault bool) error
@ -57,16 +57,19 @@ const (
Referral = OfferType(2)
)
// OfferStatus indicates the status of an offer
// OfferStatus represents the different stage an offer can have in its life-cycle.
type OfferStatus int
const (
// Done is a default offer status when an offer is not being used currently
// Done is the status of an offer that is no longer in use.
Done = OfferStatus(iota)
// Default is a offer status when an offer is used as a default offer
Default
// Active is a offer status when an offer is currently being used
// Active is the status of an offer that is currently in use.
Active
// Default is the status of an offer when there is no active offer.
Default
)
// Offer contains info needed for giving users free credits through different offer programs
@ -91,53 +94,63 @@ type Offer struct {
Type OfferType
}
// IsDefault evaluates the default status of offers for templates.
func (o Offer) IsDefault() bool {
if o.Status == Default {
return true
}
return false
// IsEmpty evaluates whether or not an on offer is empty
func (o Offer) IsEmpty() bool {
return o.Name == ""
}
// IsCurrent evaluates the current status of offers for templates.
func (o Offer) IsCurrent() bool {
if o.Status == Active {
return true
}
return false
// Offers contains a slice of offers.
type Offers []Offer
// OrganizedOffers contains a list of offers organized by status.
type OrganizedOffers struct {
Active Offer
Default Offer
Done Offers
}
// IsDone evaluates the done status of offers for templates.
func (o Offer) IsDone() bool {
if o.Status == Done {
return true
}
return false
// OfferSet provides a separation of marketing offers by type.
type OfferSet struct {
ReferralOffers OrganizedOffers
FreeCredits OrganizedOffers
}
// Offers holds a set of organized offers.
type Offers struct {
Set []Offer
}
// OrganizeOffersByStatus organizes offers by OfferStatus.
func (offers Offers) OrganizeOffersByStatus() OrganizedOffers {
var oo OrganizedOffers
// GetCurrentFromSet returns the current offer from an organized set.
func (offers Offers) GetCurrentFromSet() Offer {
var o Offer
for _, offer := range offers.Set {
if offer.IsCurrent() {
o = offer
for _, offer := range offers {
switch offer.Status {
case Active:
oo.Active = offer
case Default:
oo.Default = offer
case Done:
oo.Done = append(oo.Done, offer)
}
}
return o
return oo
}
// GetDefaultFromSet returns the current offer from an organized set.
func (offers Offers) GetDefaultFromSet() Offer {
var o Offer
for _, offer := range offers.Set {
if offer.IsDefault() {
o = offer
// OrganizeOffersByType organizes offers by OfferType.
func (offers Offers) OrganizeOffersByType() OfferSet {
var (
fc, ro Offers
offerSet OfferSet
)
for _, offer := range offers {
switch offer.Type {
case FreeCredit:
fc = append(fc, offer)
case Referral:
ro = append(ro, offer)
default:
continue
}
}
return o
offerSet.FreeCredits = fc.OrganizeOffersByStatus()
offerSet.ReferralOffers = ro.OrganizeOffersByStatus()
return offerSet
}

View File

@ -18,8 +18,9 @@ import (
"github.com/lib/pq"
"github.com/mattn/go-sqlite3"
"math/rand"
"github.com/mattn/go-sqlite3"
)
// Prevent conditional imports from causing build failures

View File

@ -1030,7 +1030,7 @@ func (m *lockedRewards) GetCurrentByType(ctx context.Context, offerType rewards.
return m.db.GetCurrentByType(ctx, offerType)
}
func (m *lockedRewards) ListAll(ctx context.Context) ([]rewards.Offer, error) {
func (m *lockedRewards) ListAll(ctx context.Context) (rewards.Offers, error) {
m.Lock()
defer m.Unlock()
return m.db.ListAll(ctx)

View File

@ -25,7 +25,7 @@ type offersDB struct {
}
// ListAll returns all offersDB from the db
func (db *offersDB) ListAll(ctx context.Context) ([]rewards.Offer, error) {
func (db *offersDB) ListAll(ctx context.Context) (rewards.Offers, error) {
offersDbx, err := db.db.All_Offer(ctx)
if err != nil {
return nil, offerErr.Wrap(err)
@ -153,7 +153,7 @@ func (db *offersDB) Finish(ctx context.Context, oID int) error {
return nil
}
func offersFromDBX(offersDbx []*dbx.Offer) ([]rewards.Offer, error) {
func offersFromDBX(offersDbx []*dbx.Offer) (rewards.Offers, error) {
var offers []rewards.Offer
errList := new(errs.Group)

View File

@ -181,4 +181,8 @@ input{
.edit-offer:hover{
text-decoration: underline;
color: #656565;
}
.stop-offer{
cursor:pointer;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -13,9 +13,10 @@ See LICENSE for copying information. -->
<div class="col col-heading">Status</div>
</div><hr>
<div class="row offer-heading ">
<p class="offer-type">default offer</p>
<p class="offer-type">Default&nbsp;Offer</p>
</div>
{{$defaultOffer := .FreeCredits.GetDefaultFromSet}}
{{$defaultOffer := .FreeCredits.Default}}
{{if not $defaultOffer.IsEmpty}}
<div class="row data-row">
<div class="col ml-3">{{$defaultOffer.Name}}</div>
<div class="col">${{$defaultOffer.AwardCredit}}</div>
@ -25,11 +26,12 @@ See LICENSE for copying information. -->
<div class="col">{{printf "%.10s" $defaultOffer.ExpiresAt}}</div>
<div class="col"></div>
</div><hr>
{{end}}
<div class="row offer-heading ">
<p class="offer-type">current offer</p>
<p class="offer-type">Current&nbsp;Offer</p>
</div>
{{if gt (len .FreeCredits.Set) 0}}
{{$currentOffer := .FreeCredits.GetCurrentFromSet}}
{{$currentOffer := .FreeCredits.Active}}
{{if not $currentOffer.IsEmpty}}
<div class="row data-row">
<div class="col ml-3">{{$currentOffer.Name}}</div>
<div class="col">${{$currentOffer.AwardCredit}}</div>
@ -38,30 +40,26 @@ See LICENSE for copying information. -->
<div class="col">{{printf "%.10s" $currentOffer.CreatedAt}}</div>
<div class="col">{{printf "%.10s" $currentOffer.ExpiresAt}}</div>
<div class="col stop-offer">
<span data-toggle="modal" data-target=".stop-referral-offer-modal">
<span data-toggle="modal" data-target=".stop-free-credit-modal">
<strong>Live &#183;</strong>
</span>
</div>
</div><hr>
{{end}}
<div class="row offer-heading ">
<p class="offer-type">other offers</p>
</div>
{{if gt (len .FreeCredits.Set) 0}}
{{range .FreeCredits.Set}}
{{$offer := .}}
{{if $offer.IsDone}}
<div class="row data-row">
<div class="col ml-3">{{$offer.Name}}</div>
<div class="col">${{$offer.AwardCredit}}</div>
<div class="col">{{$offer.NumRedeemed}}</div>
<div class="col">{{$offer.RedeemableCap}}</div>
<div class="col">{{printf "%.10s" $offer.CreatedAt}}</div>
<div class="col">{{printf "%.10s" $offer.ExpiresAt}}</div>
<div class="col">off</div>
</div><hr>
{{end}}
{{end}}
{{end}}
{{template "stopFreeCredit" .}}
<div class="row offer-heading ">
<p class="offer-type">Other&nbsp;Offers</p>
</div>
{{range .FreeCredits.Done}}
<div class="row data-row">
<div class="col ml-3">{{.Name}}</div>
<div class="col">${{.AwardCredit}}</div>
<div class="col">{{.NumRedeemed}}</div>
<div class="col">{{.RedeemableCap}}</div>
<div class="col">{{printf "%.10s" .CreatedAt}}</div>
<div class="col">{{printf "%.10s" .ExpiresAt}}</div>
<div class="col">off</div>
</div><hr>
{{end}}
</div>
{{end}}

View File

@ -16,7 +16,8 @@ See LICENSE for copying information. -->
<div class="row offer-heading ">
<p class="offer-type">Default&nbsp;Offer</p>
</div>
{{$defaultOffer := .ReferralOffers.GetDefaultFromSet}}
{{$defaultOffer := .ReferralOffers.Default}}
{{if not $defaultOffer.IsEmpty}}
<div class="row data-row">
<div class="col ml-3">{{$defaultOffer.Name}}</div>
<div class="col">${{$defaultOffer.InviteeCredit}}</div>
@ -27,11 +28,12 @@ See LICENSE for copying information. -->
<div class="col">&#8734;</div>
<div class="col"></div>
</div><hr>
{{end}}
<div class="row offer-heading ">
<p class="offer-type">Current&nbsp;Offer</p>
</div>
{{if gt (len .ReferralOffers.Set) 0}}
{{$currentOffer := .ReferralOffers.GetCurrentFromSet}}
{{$currentOffer := .ReferralOffers.Active}}
{{if not $currentOffer.IsEmpty}}
<div class="row data-row">
<div class="col ml-3">{{$currentOffer.Name}}</div>
<div class="col">${{$currentOffer.InviteeCredit}}</div>
@ -47,25 +49,21 @@ See LICENSE for copying information. -->
</div>
</div><hr>
{{end}}
{{template "stopReferralOffer" .}}
<div class="row offer-heading ">
<p class="offer-type">Other&nbsp;Offers</p>
</div>
{{if gt (len .ReferralOffers.Set) 0}}
{{range .ReferralOffers.Set}}
{{$offer := .}}
{{if $offer.IsDone}}
<div class="row data-row">
<div class="col ml-3">{{$offer.Name}}</div>
<div class="col">${{$offer.InviteeCredit}}</div>
<div class="col">${{$offer.AwardCredit}}</div>
<div class="col">{{.NumRedeemed}}</div>
<div class="col">{{.RedeemableCap}}</div>
<div class="col">{{printf "%.10s" .CreatedAt}}</div>
<div class="col">{{printf "%.10s" .ExpiresAt}}</div>
<div class="col">off</div>
</div><hr>
{{end}}
{{range .ReferralOffers.Done}}
<div class="row data-row">
<div class="col ml-3">{{.Name}}</div>
<div class="col">${{.InviteeCredit}}</div>
<div class="col">${{.AwardCredit}}</div>
<div class="col">{{.NumRedeemed}}</div>
<div class="col">{{.RedeemableCap}}</div>
<div class="col">{{printf "%.10s" .CreatedAt}}</div>
<div class="col">{{printf "%.10s" .ExpiresAt}}</div>
<div class="col">off</div>
</div><hr>
{{end}}
{{end}}
</div>
{{end}}

View File

@ -0,0 +1,30 @@
<!-- Copyright (C) 2019 Storj Labs, Inc.
See LICENSE for copying information. -->
{{define "stopFreeCredit"}}
<div class="modal fade stop-free-credit-modal mt-5" tabindex="-1" role="dialog" aria-labelledby="confirm" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="row d-flex justify-content-between">
<img class="mt-3 ml-5" src="/static/img/warning.png" height="30" width="30"/>
<p class="mt-3">Replace
<strong>{{.FreeCredits.Active.Name}}</strong> with
<strong>{{.FreeCredits.Default.Name}}</strong> ?
</p>
<div class="mt-2 mb-2 d-flex">
<a href="/">
<button class="btn btn-outline-secondary mr-1">
Cancel
</button>
</a>
<form method="POST" action="/stop/{{.FreeCredits.Active.ID}}">
<button class="btn btn-primary mr-4" type="submit">
Yes
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,30 @@
<!-- Copyright (C) 2019 Storj Labs, Inc.
See LICENSE for copying information. -->
{{define "stopReferralOffer"}}
<div class="modal fade stop-referral-offer-modal mt-5" tabindex="-1" role="dialog" aria-labelledby="confirm" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="row d-flex justify-content-between">
<img class="mt-3 ml-5" src="/static/img/warning.png" height="30" width="30"/>
<p class="mt-3">Replace
<strong>{{.ReferralOffers.Active.Name}}</strong> with
<strong>{{.ReferralOffers.Default.Name}}</strong> ?
</p>
<div class="mt-2 mb-2 d-flex">
<a href="/">
<button class="btn btn-outline-secondary mr-1">
Cancel
</button>
</a>
<form method="POST" action="/stop/{{.ReferralOffers.Active.ID}}">
<button class="btn btn-primary mr-4" type="submit">
Yes
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}