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:
parent
94eeb58b45
commit
0d294103e9
@ -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)
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -181,4 +181,8 @@ input{
|
||||
.edit-offer:hover{
|
||||
text-decoration: underline;
|
||||
color: #656565;
|
||||
}
|
||||
|
||||
.stop-offer{
|
||||
cursor:pointer;
|
||||
}
|
BIN
web/marketing/img/warning.png
Normal file
BIN
web/marketing/img/warning.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -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 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 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 ·</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 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}}
|
@ -16,7 +16,8 @@ See LICENSE for copying information. -->
|
||||
<div class="row offer-heading ">
|
||||
<p class="offer-type">Default 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">∞</div>
|
||||
<div class="col"></div>
|
||||
</div><hr>
|
||||
{{end}}
|
||||
<div class="row offer-heading ">
|
||||
<p class="offer-type">Current 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 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}}
|
30
web/marketing/pages/stop-free-credit.html
Normal file
30
web/marketing/pages/stop-free-credit.html
Normal 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}}
|
30
web/marketing/pages/stop-referral-offer.html
Normal file
30
web/marketing/pages/stop-referral-offer.html
Normal 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}}
|
Loading…
Reference in New Issue
Block a user