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"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/zeebo/errs" "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. // commonPages returns templates that are required for all routes.
func (s *Server) commonPages() []string { func (s *Server) commonPages() []string {
return []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.HandleFunc("/", s.GetOffers)
mux.PathPrefix("/static/").Handler(fs) mux.PathPrefix("/static/").Handler(fs)
mux.HandleFunc("/create/{offer_type}", s.CreateOffer) mux.HandleFunc("/create/{offer_type}", s.CreateOffer)
mux.HandleFunc("/stop/{offer_id}", s.StopOffer)
} }
s.server.Handler = mux s.server.Handler = mux
@ -119,7 +97,7 @@ func (s *Server) GetOffers(w http.ResponseWriter, req *http.Request) {
return 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.log.Error("failed to execute template", zap.Error(err))
s.serveInternalError(w, req, 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, "referral-offers-modal.html"),
filepath.Join(s.templateDir, "free-offers.html"), filepath.Join(s.templateDir, "free-offers.html"),
filepath.Join(s.templateDir, "free-offers-modal.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(), pageNotFoundFiles := append(s.commonPages(),
@ -147,7 +127,9 @@ func (s *Server) parseTemplates() (err error) {
filepath.Join(s.templateDir, "err.html"), 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 { if err != nil {
return Error.Wrap(err) return Error.Wrap(err)
} }
@ -202,6 +184,24 @@ func (s *Server) CreateOffer(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/", http.StatusSeeOther) 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. // serveNotFound handles 404 errors and defaults to 500 if template parsing fails.
func (s *Server) serveNotFound(w http.ResponseWriter, req *http.Request) { func (s *Server) serveNotFound(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)

View File

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

View File

@ -12,7 +12,7 @@ import (
// DB holds information about offer // DB holds information about offer
type DB interface { type DB interface {
ListAll(ctx context.Context) ([]Offer, error) ListAll(ctx context.Context) (Offers, error)
GetCurrentByType(ctx context.Context, offerType OfferType) (*Offer, error) GetCurrentByType(ctx context.Context, offerType OfferType) (*Offer, error)
Create(ctx context.Context, offer *NewOffer) (*Offer, error) Create(ctx context.Context, offer *NewOffer) (*Offer, error)
Redeem(ctx context.Context, offerID int, isDefault bool) error Redeem(ctx context.Context, offerID int, isDefault bool) error
@ -57,16 +57,19 @@ const (
Referral = OfferType(2) 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 type OfferStatus int
const ( 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) Done = OfferStatus(iota)
// Default is a offer status when an offer is used as a default offer
Default // Active is the status of an offer that is currently in use.
// Active is a offer status when an offer is currently being used
Active 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 // Offer contains info needed for giving users free credits through different offer programs
@ -91,53 +94,63 @@ type Offer struct {
Type OfferType Type OfferType
} }
// IsDefault evaluates the default status of offers for templates. // IsEmpty evaluates whether or not an on offer is empty
func (o Offer) IsDefault() bool { func (o Offer) IsEmpty() bool {
if o.Status == Default { return o.Name == ""
return true
}
return false
} }
// IsCurrent evaluates the current status of offers for templates. // Offers contains a slice of offers.
func (o Offer) IsCurrent() bool { type Offers []Offer
if o.Status == Active {
return true // OrganizedOffers contains a list of offers organized by status.
} type OrganizedOffers struct {
return false Active Offer
Default Offer
Done Offers
} }
// IsDone evaluates the done status of offers for templates. // OfferSet provides a separation of marketing offers by type.
func (o Offer) IsDone() bool { type OfferSet struct {
if o.Status == Done { ReferralOffers OrganizedOffers
return true FreeCredits OrganizedOffers
}
return false
} }
// Offers holds a set of organized offers. // OrganizeOffersByStatus organizes offers by OfferStatus.
type Offers struct { func (offers Offers) OrganizeOffersByStatus() OrganizedOffers {
Set []Offer var oo OrganizedOffers
}
// GetCurrentFromSet returns the current offer from an organized set. for _, offer := range offers {
func (offers Offers) GetCurrentFromSet() Offer { switch offer.Status {
var o Offer case Active:
for _, offer := range offers.Set { oo.Active = offer
if offer.IsCurrent() { case Default:
o = offer oo.Default = offer
case Done:
oo.Done = append(oo.Done, offer)
} }
} }
return o return oo
} }
// GetDefaultFromSet returns the current offer from an organized set. // OrganizeOffersByType organizes offers by OfferType.
func (offers Offers) GetDefaultFromSet() Offer { func (offers Offers) OrganizeOffersByType() OfferSet {
var o Offer var (
for _, offer := range offers.Set { fc, ro Offers
if offer.IsDefault() { offerSet OfferSet
o = offer )
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/lib/pq"
"github.com/mattn/go-sqlite3"
"math/rand" "math/rand"
"github.com/mattn/go-sqlite3"
) )
// Prevent conditional imports from causing build failures // 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) 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() m.Lock()
defer m.Unlock() defer m.Unlock()
return m.db.ListAll(ctx) return m.db.ListAll(ctx)

View File

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

View File

@ -181,4 +181,8 @@ input{
.edit-offer:hover{ .edit-offer:hover{
text-decoration: underline; text-decoration: underline;
color: #656565; 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 class="col col-heading">Status</div>
</div><hr> </div><hr>
<div class="row offer-heading "> <div class="row offer-heading ">
<p class="offer-type">default offer</p> <p class="offer-type">Default&nbsp;Offer</p>
</div> </div>
{{$defaultOffer := .FreeCredits.GetDefaultFromSet}} {{$defaultOffer := .FreeCredits.Default}}
{{if not $defaultOffer.IsEmpty}}
<div class="row data-row"> <div class="row data-row">
<div class="col ml-3">{{$defaultOffer.Name}}</div> <div class="col ml-3">{{$defaultOffer.Name}}</div>
<div class="col">${{$defaultOffer.AwardCredit}}</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">{{printf "%.10s" $defaultOffer.ExpiresAt}}</div>
<div class="col"></div> <div class="col"></div>
</div><hr> </div><hr>
{{end}}
<div class="row offer-heading "> <div class="row offer-heading ">
<p class="offer-type">current offer</p> <p class="offer-type">Current&nbsp;Offer</p>
</div> </div>
{{if gt (len .FreeCredits.Set) 0}} {{$currentOffer := .FreeCredits.Active}}
{{$currentOffer := .FreeCredits.GetCurrentFromSet}} {{if not $currentOffer.IsEmpty}}
<div class="row data-row"> <div class="row data-row">
<div class="col ml-3">{{$currentOffer.Name}}</div> <div class="col ml-3">{{$currentOffer.Name}}</div>
<div class="col">${{$currentOffer.AwardCredit}}</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.CreatedAt}}</div>
<div class="col">{{printf "%.10s" $currentOffer.ExpiresAt}}</div> <div class="col">{{printf "%.10s" $currentOffer.ExpiresAt}}</div>
<div class="col stop-offer"> <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> <strong>Live &#183;</strong>
</span> </span>
</div> </div>
</div><hr> </div><hr>
{{end}} {{end}}
<div class="row offer-heading "> {{template "stopFreeCredit" .}}
<p class="offer-type">other offers</p> <div class="row offer-heading ">
</div> <p class="offer-type">Other&nbsp;Offers</p>
{{if gt (len .FreeCredits.Set) 0}} </div>
{{range .FreeCredits.Set}} {{range .FreeCredits.Done}}
{{$offer := .}} <div class="row data-row">
{{if $offer.IsDone}} <div class="col ml-3">{{.Name}}</div>
<div class="row data-row"> <div class="col">${{.AwardCredit}}</div>
<div class="col ml-3">{{$offer.Name}}</div> <div class="col">{{.NumRedeemed}}</div>
<div class="col">${{$offer.AwardCredit}}</div> <div class="col">{{.RedeemableCap}}</div>
<div class="col">{{$offer.NumRedeemed}}</div> <div class="col">{{printf "%.10s" .CreatedAt}}</div>
<div class="col">{{$offer.RedeemableCap}}</div> <div class="col">{{printf "%.10s" .ExpiresAt}}</div>
<div class="col">{{printf "%.10s" $offer.CreatedAt}}</div> <div class="col">off</div>
<div class="col">{{printf "%.10s" $offer.ExpiresAt}}</div> </div><hr>
<div class="col">off</div>
</div><hr>
{{end}}
{{end}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

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