mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat(wip): implement stripe webhooks
This commit is contained in:
parent
f39ecc46bd
commit
05ea05cdf4
@ -123,10 +123,12 @@ type serverConfig struct {
|
||||
}
|
||||
|
||||
type subscriptionsConfig struct {
|
||||
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
|
||||
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
|
||||
StripeSecret string `yaml:"stripe_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET"`
|
||||
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
|
||||
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
|
||||
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
|
||||
StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"`
|
||||
StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"`
|
||||
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
|
||||
StandardPrice string `yaml:"-"`
|
||||
}
|
||||
|
||||
type sentryConfig struct {
|
||||
@ -189,7 +191,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
|
||||
MaxAge: maxAge,
|
||||
Secure: !c.Security.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
|
2
main.go
2
main.go
@ -216,6 +216,7 @@ func main() {
|
||||
// MVC Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService)
|
||||
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||
@ -253,6 +254,7 @@ func main() {
|
||||
summaryHandler.RegisterRoutes(rootRouter)
|
||||
leaderboardHandler.RegisterRoutes(rootRouter)
|
||||
settingsHandler.RegisterRoutes(rootRouter)
|
||||
subscriptionHandler.RegisterRoutes(rootRouter)
|
||||
relayHandler.RegisterRoutes(rootRouter)
|
||||
|
||||
// API route registrations
|
||||
|
@ -35,7 +35,6 @@ type User struct {
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||
SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
StripeCustomerId string `json:"-"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
|
@ -3,14 +3,15 @@ package view
|
||||
import "github.com/muety/wakapi/models"
|
||||
|
||||
type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Labels []*SettingsVMCombinedLabel
|
||||
Projects []string
|
||||
ApiKey string
|
||||
Success string
|
||||
Error string
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Labels []*SettingsVMCombinedLabel
|
||||
Projects []string
|
||||
SubscriptionPrice string
|
||||
ApiKey string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
type SettingsVMCombinedAlias struct {
|
||||
@ -24,6 +25,10 @@ type SettingsVMCombinedLabel struct {
|
||||
Values []string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
|
||||
return s.SubscriptionPrice != ""
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
|
@ -733,14 +733,21 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
}
|
||||
|
||||
// subscriptions
|
||||
var subscriptionPrice string
|
||||
if h.config.Subscriptions.Enabled {
|
||||
subscriptionPrice = h.config.Subscriptions.StandardPrice
|
||||
}
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Labels: combinedLabels,
|
||||
Projects: projects,
|
||||
ApiKey: user.ApiKey,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Labels: combinedLabels,
|
||||
Projects: projects,
|
||||
ApiKey: user.ApiKey,
|
||||
SubscriptionPrice: subscriptionPrice,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,21 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
portalSession "github.com/stripe/stripe-go/v74/billingportal/session"
|
||||
checkoutSession "github.com/stripe/stripe-go/v74/checkout/session"
|
||||
stripePortalSession "github.com/stripe/stripe-go/v74/billingportal/session"
|
||||
stripeCheckoutSession "github.com/stripe/stripe-go/v74/checkout/session"
|
||||
stripeCustomer "github.com/stripe/stripe-go/v74/customer"
|
||||
stripePrice "github.com/stripe/stripe-go/v74/price"
|
||||
"github.com/stripe/stripe-go/v74/webhook"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@ -27,7 +34,15 @@ func NewSubscriptionHandler(
|
||||
config := conf.Get()
|
||||
|
||||
if config.Subscriptions.Enabled {
|
||||
stripe.Key = config.Subscriptions.StripeApiKey
|
||||
stripe.Key = config.Subscriptions.StripeSecretKey
|
||||
|
||||
price, err := stripePrice.Get(config.Subscriptions.StandardPriceId, nil)
|
||||
if err != nil {
|
||||
logbuch.Fatal("failed to fetch stripe plan details: %v", err)
|
||||
}
|
||||
config.Subscriptions.StandardPrice = fmt.Sprintf("%2.f €", price.UnitAmountDecimal/100.0) // TODO: respect actual currency
|
||||
|
||||
logbuch.Info("enabling subscriptions with stripe payment for %s / month", config.Subscriptions.StandardPrice)
|
||||
}
|
||||
|
||||
return &SubscriptionHandler{
|
||||
@ -45,16 +60,17 @@ func (h *SubscriptionHandler) RegisterRoutes(router *mux.Router) {
|
||||
return
|
||||
}
|
||||
|
||||
r := router.PathPrefix("/subscription").Subrouter()
|
||||
r.Use(
|
||||
subRouterPublic := router.PathPrefix("/subscription").Subrouter()
|
||||
subRouterPublic.Path("/success").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutSuccess)
|
||||
subRouterPublic.Path("/cancel").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutCancel)
|
||||
subRouterPublic.Path("/webhook").Methods(http.MethodPost).HandlerFunc(h.PostWebhook)
|
||||
|
||||
subRouterPrivate := subRouterPublic.PathPrefix("").Subrouter()
|
||||
subRouterPrivate.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
|
||||
)
|
||||
r.Path("/success").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutSuccess)
|
||||
r.Path("/cancel").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutCancel)
|
||||
r.Path("/checkout").Methods(http.MethodPost).HandlerFunc(h.PostCheckout)
|
||||
r.Path("/portal").Methods(http.MethodPost).HandlerFunc(h.PostPortal)
|
||||
|
||||
router.Path("/webhook").Methods(http.MethodPost).HandlerFunc(h.PostWebhook)
|
||||
subRouterPrivate.Path("/checkout").Methods(http.MethodPost).HandlerFunc(h.PostCheckout)
|
||||
subRouterPrivate.Path("/portal").Methods(http.MethodPost).HandlerFunc(h.PostPortal)
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
@ -64,12 +80,12 @@ func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
if user.Email == "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing e-mail address"), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "missing e-mail address"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing form values"), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "missing form values"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@ -81,15 +97,16 @@ func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Reques
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
CustomerEmail: &user.Email,
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s%s/subscription/success", h.config.Server.PublicUrl, h.config.Server.BasePath)),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s%s/subscription/cacnel", h.config.Server.PublicUrl, h.config.Server.BasePath)),
|
||||
CustomerEmail: &user.Email,
|
||||
ClientReferenceID: &user.Email,
|
||||
SuccessURL: stripe.String(fmt.Sprintf("%s%s/subscription/success", h.config.Server.PublicUrl, h.config.Server.BasePath)),
|
||||
CancelURL: stripe.String(fmt.Sprintf("%s%s/subscription/cancel", h.config.Server.PublicUrl, h.config.Server.BasePath)),
|
||||
}
|
||||
|
||||
session, err := checkoutSession.New(checkoutParams)
|
||||
session, err := stripeCheckoutSession.New(checkoutParams)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("failed to create stripe checkout session: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "something went wrong"), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "something went wrong"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@ -102,20 +119,26 @@ func (h *SubscriptionHandler) PostPortal(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
if user.StripeCustomerId == "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing stripe customer reference, please contact us!"), http.StatusFound)
|
||||
if user.Email == "" {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "no subscription found with your e-mail address, please contact us!"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
customer, err := h.findStripeCustomerByEmail(user.Email)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "no subscription found with your e-mail address, please contact us!"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
portalParams := &stripe.BillingPortalSessionParams{
|
||||
Customer: &user.StripeCustomerId,
|
||||
Customer: &customer.ID,
|
||||
ReturnURL: &h.config.Server.PublicUrl,
|
||||
}
|
||||
|
||||
session, err := portalSession.New(portalParams)
|
||||
session, err := stripePortalSession.New(portalParams)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("failed to create stripe portal session: %v", err)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "something went wrong"), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "something went wrong"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@ -123,13 +146,86 @@ func (h *SubscriptionHandler) PostPortal(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: implement
|
||||
bodyReader := http.MaxBytesReader(w, r.Body, int64(65536))
|
||||
payload, err := ioutil.ReadAll(bodyReader)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error in stripe webhook request: %v", err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEventWithOptions(payload, r.Header.Get("Stripe-Signature"), h.config.Subscriptions.StripeEndpointSecret, webhook.ConstructEventOptions{
|
||||
IgnoreAPIVersionMismatch: true,
|
||||
})
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("stripe webhook signature verification failed: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "customer.subscription.deleted",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.created":
|
||||
subscription, customer, err := h.handleParseSubscription(w, r, event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
logbuch.Info("received stripe subscription event of type '%s' for subscription '%d' (customer '%s' with email '%s').", event.Type, subscription.ID, customer.ID, customer.Email)
|
||||
// TODO: handle
|
||||
// if status == 'active', set active subscription date to current_period_end
|
||||
// if status == 'canceled' or 'unpaid', clear active subscription date, if < now
|
||||
// example payload: https://pastr.de/p/k7bx3alx38b1iawo6amtx09k
|
||||
default:
|
||||
logbuch.Warn("got stripe event '%s' with no handler defined", event.Type)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) GetCheckoutSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?success=%s", h.config.Server.BasePath, "you have successfully subscribed to wakapi!"), http.StatusFound)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s#subscription", h.config.Server.BasePath, "you have successfully subscribed to Wakapi!"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) GetCheckoutCancel(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) handleParseSubscription(w http.ResponseWriter, r *http.Request, event stripe.Event) (*stripe.Subscription, *stripe.Customer, error) {
|
||||
var subscription stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
|
||||
conf.Log().Request(r).Error("failed to parse stripe webhook payload: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
customer, err := stripeCustomer.Get(subscription.Customer.ID, nil)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("failed to fetch stripe customer (%s): %v", subscription.Customer.ID, err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logbuch.Info("associated stripe customer %s with user %s", customer.ID, customer.Email)
|
||||
|
||||
return &subscription, customer, nil
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.Customer, error) {
|
||||
params := &stripe.CustomerSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf(`email:"%s"`, email),
|
||||
},
|
||||
}
|
||||
|
||||
results := stripeCustomer.Search(params)
|
||||
if err := results.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if results.Next() {
|
||||
return results.Customer(), nil
|
||||
} else {
|
||||
return nil, errors.New("no customer found with given criteria")
|
||||
}
|
||||
}
|
||||
|
@ -49,9 +49,11 @@
|
||||
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('integrations'), 'hover:text-gray-500': !isActive('integrations') }">
|
||||
<a href="settings#integrations" @click="updateTab">Integrations</a>
|
||||
</li>
|
||||
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('integrations'), 'hover:text-gray-500': !isActive('subscription') }">
|
||||
{{ if .SubscriptionsEnabled }}
|
||||
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('subscription'), 'hover:text-gray-500': !isActive('subscription') }">
|
||||
<a href="settings#subscription" @click="updateTab">Subscription</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('danger_zone'), 'hover:text-gray-500': !isActive('danger_zone') }">
|
||||
<a href="settings#danger_zone" @click="updateTab">Danger Zone</a>
|
||||
</li>
|
||||
@ -605,14 +607,12 @@
|
||||
<div class="flex items-center w-1/3">
|
||||
<img class="with-url-src"
|
||||
src="api/badge/{{ .User.ID }}/interval:today?label=today"
|
||||
alt="Badge"
|
||||
/>
|
||||
alt="Badge"/>
|
||||
</div>
|
||||
<input
|
||||
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
|
||||
value="%s/api/badge/{{ .User.ID }}/interval:today?label=today"
|
||||
readonly
|
||||
>
|
||||
readonly>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4 mt-4">
|
||||
@ -625,22 +625,19 @@
|
||||
<input
|
||||
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
|
||||
value="%s/api/badge/{{ .User.ID }}/{{ .User.ID }}/interval:30_days?label=last 30d"
|
||||
readonly
|
||||
>
|
||||
readonly>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4 mt-4">
|
||||
<div class="flex items-center w-1/3">
|
||||
<img class="with-url-src"
|
||||
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
|
||||
alt="Shields.io badge"
|
||||
/>
|
||||
alt="Shields.io badge"/>
|
||||
</div>
|
||||
<input
|
||||
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
|
||||
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
|
||||
readonly
|
||||
>
|
||||
readonly>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
@ -677,21 +674,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .SubscriptionsEnabled }}
|
||||
<div v-cloak id="subscription" class="tab flex flex-col space-y-4" v-if="isActive('subscription')">
|
||||
<div class="w-full lg:w-3/4">
|
||||
<form action="" method="post" class="flex mb-8" id="form-subscription-checkout">
|
||||
<form action="subscription/checkout" method="post" class="flex mb-8" id="form-subscription-checkout">
|
||||
<div class="w-1/2 mr-4 inline-block">
|
||||
<span class="font-semibold text-gray-300">Subscription</span>
|
||||
<span class="block text-sm text-gray-600">
|
||||
Lorem ipsum dolor sit amet
|
||||
By default, this Wakapi instance will only store historical coding activity for 12 months. However, if you want to support the project, you can opt for a paid subscription for {{ .SubscriptionPrice }} / month to get unlimited history with no restrictions.
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-1/2 ml-4 flex items-center">
|
||||
<button type="submit" class="btn-primary ml-1">Subscribe</button>
|
||||
<button type="submit" class="btn-primary ml-1">Subscribe ({{ .SubscriptionPrice }} / mo)</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div v-cloak id="danger_zone" class="tab flex flex-col space-y-4" v-if="isActive('danger_zone')">
|
||||
<div class="w-full lg:w-3/4">
|
||||
|
Loading…
Reference in New Issue
Block a user