feat: support Pricings flow (#2250)

* feat: fix price display

* feat: support subscription

* feat: fix select-plan-> signup -> buy-plan -> login flow

* feat: support paid-user to login and jump to the pricing page

* feat: support more subscription state

* feat: add payment providers for plan

* feat: format code

* feat: gofumpt

* feat: redirect to buy-plan-result page when user have pending subscription

* feat: response err when pricing don't exit

* Update PricingListPage.js

* Update ProductBuyPage.js

* Update LoginPage.js

---------

Co-authored-by: hsluoyz <hsluoyz@qq.com>
This commit is contained in:
haiwu
2023-08-24 23:20:50 +08:00
committed by GitHub
parent 8073dfa88c
commit 05b2f00057
31 changed files with 759 additions and 295 deletions

View File

@ -87,6 +87,7 @@ p, *, *, GET, /api/get-prometheus-info, *, *
p, *, *, *, /api/metrics, *, * p, *, *, *, /api/metrics, *, *
p, *, *, GET, /api/get-pricing, *, * p, *, *, GET, /api/get-pricing, *, *
p, *, *, GET, /api/get-plan, *, * p, *, *, GET, /api/get-plan, *, *
p, *, *, GET, /api/get-subscription, *, *
p, *, *, GET, /api/get-organization-names, *, * p, *, *, GET, /api/get-organization-names, *, *
` `

View File

@ -153,12 +153,22 @@ func (c *ApiController) Signup() {
return return
} }
userType := "normal-user"
if authForm.Plan != "" && authForm.Pricing != "" {
err = object.CheckPricingAndPlan(authForm.Organization, authForm.Pricing, authForm.Plan)
if err != nil {
c.ResponseError(err.Error())
return
}
userType = "paid-user"
}
user := &object.User{ user := &object.User{
Owner: authForm.Organization, Owner: authForm.Organization,
Name: username, Name: username,
CreatedTime: util.GetCurrentTime(), CreatedTime: util.GetCurrentTime(),
Id: id, Id: id,
Type: "normal-user", Type: userType,
Password: authForm.Password, Password: authForm.Password,
DisplayName: authForm.Name, DisplayName: authForm.Name,
Avatar: organization.DefaultAvatar, Avatar: organization.DefaultAvatar,
@ -210,7 +220,7 @@ func (c *ApiController) Signup() {
return return
} }
if application.HasPromptPage() { if application.HasPromptPage() && user.Type == "normal-user" {
// The prompt page needs the user to be signed in // The prompt page needs the user to be signed in
c.SetSessionUsername(user.GetId()) c.SetSessionUsername(user.GetId())
} }
@ -227,15 +237,6 @@ func (c *ApiController) Signup() {
return return
} }
isSignupFromPricing := authForm.Plan != "" && authForm.Pricing != ""
if isSignupFromPricing {
_, err = object.Subscribe(organization.Name, user.Name, authForm.Plan, authForm.Pricing)
if err != nil {
c.ResponseError(err.Error())
return
}
}
record := object.NewRecord(c.Ctx) record := object.NewRecord(c.Ctx)
record.Organization = application.Organization record.Organization = application.Organization
record.User = user.Name record.User = user.Name

View File

@ -78,6 +78,46 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
} }
} }
// check whether paid-user have active subscription
if user.Type == "paid-user" {
subscriptions, err := object.GetSubscriptionsByUser(user.Owner, user.Name)
if err != nil {
c.ResponseError(err.Error())
return
}
existActiveSubscription := false
for _, subscription := range subscriptions {
if subscription.State == object.SubStateActive {
existActiveSubscription = true
break
}
}
if !existActiveSubscription {
// check pending subscription
for _, sub := range subscriptions {
if sub.State == object.SubStatePending {
c.ResponseOk("BuyPlanResult", sub)
return
}
}
// paid-user does not have active or pending subscription, find the default pricing of application
pricing, err := object.GetApplicationDefaultPricing(application.Organization, application.Name)
if err != nil {
c.ResponseError(err.Error())
return
}
if pricing == nil {
c.ResponseError(fmt.Sprintf(c.T("auth:paid-user %s does not have active or pending subscription and the application: %s does not have default pricing"), user.Name, application.Name))
return
} else {
// let the paid-user select plan
c.ResponseOk("SelectPlan", pricing)
return
}
}
}
if form.Type == ResponseTypeLogin { if form.Type == ResponseTypeLogin {
c.SetSessionUsername(userId) c.SetSessionUsername(userId)
util.LogInfo(c.Ctx, "API: [%s] signed in", userId) util.LogInfo(c.Ctx, "API: [%s] signed in", userId)

View File

@ -16,6 +16,7 @@ package controllers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"github.com/beego/beego/utils/pagination" "github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object" "github.com/casdoor/casdoor/object"
@ -82,7 +83,10 @@ func (c *ApiController) GetPlan() {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
if plan == nil {
c.ResponseError(fmt.Sprintf(c.T("plan:The plan: %s does not exist"), id))
return
}
if includeOption { if includeOption {
options, err := object.GetPermissionsByRole(plan.Role) options, err := object.GetPermissionsByRole(plan.Role)
if err != nil { if err != nil {
@ -117,7 +121,20 @@ func (c *ApiController) UpdatePlan() {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
if plan.Product != "" {
planId := util.GetId(plan.Owner, plan.Product)
product, err := object.GetProduct(planId)
if err != nil {
c.ResponseError(err.Error())
return
}
object.UpdateProductForPlan(&plan, product)
_, err = object.UpdateProduct(planId, product)
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.Data["json"] = wrapActionResponse(object.UpdatePlan(id, &plan)) c.Data["json"] = wrapActionResponse(object.UpdatePlan(id, &plan))
c.ServeJSON() c.ServeJSON()
} }
@ -136,7 +153,14 @@ func (c *ApiController) AddPlan() {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
// Create a related product for plan
product := object.CreateProductForPlan(&plan)
_, err = object.AddProduct(product)
if err != nil {
c.ResponseError(err.Error())
return
}
plan.Product = product.Name
c.Data["json"] = wrapActionResponse(object.AddPlan(&plan)) c.Data["json"] = wrapActionResponse(object.AddPlan(&plan))
c.ServeJSON() c.ServeJSON()
} }
@ -155,7 +179,13 @@ func (c *ApiController) DeletePlan() {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
if plan.Product != "" {
_, err = object.DeleteProduct(&object.Product{Owner: plan.Owner, Name: plan.Product})
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.Data["json"] = wrapActionResponse(object.DeletePlan(&plan)) c.Data["json"] = wrapActionResponse(object.DeletePlan(&plan))
c.ServeJSON() c.ServeJSON()
} }

View File

@ -16,6 +16,7 @@ package controllers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"github.com/beego/beego/utils/pagination" "github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object" "github.com/casdoor/casdoor/object"
@ -80,7 +81,10 @@ func (c *ApiController) GetPricing() {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
if pricing == nil {
c.ResponseError(fmt.Sprintf(c.T("pricing:The pricing: %s does not exist"), id))
return
}
c.ResponseOk(pricing) c.ResponseOk(pricing)
} }

View File

@ -161,10 +161,17 @@ func (c *ApiController) DeleteProduct() {
// @router /buy-product [post] // @router /buy-product [post]
func (c *ApiController) BuyProduct() { func (c *ApiController) BuyProduct() {
id := c.Input().Get("id") id := c.Input().Get("id")
providerName := c.Input().Get("providerName")
host := c.Ctx.Request.Host host := c.Ctx.Request.Host
providerName := c.Input().Get("providerName")
userId := c.GetSessionUsername() // buy `pricingName/planName` for `paidUserName`
pricingName := c.Input().Get("pricingName")
planName := c.Input().Get("planName")
paidUserName := c.Input().Get("userName")
owner, _ := util.GetOwnerAndNameFromId(id)
userId := util.GetId(owner, paidUserName)
if paidUserName == "" {
userId = c.GetSessionUsername()
}
if userId == "" { if userId == "" {
c.ResponseError(c.T("general:Please login first")) c.ResponseError(c.T("general:Please login first"))
return return
@ -175,13 +182,12 @@ func (c *ApiController) BuyProduct() {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return
} }
if user == nil { if user == nil {
c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId)) c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId))
return return
} }
payUrl, orderId, err := object.BuyProduct(id, providerName, user, host) payUrl, orderId, err := object.BuyProduct(id, user, providerName, pricingName, planName, host)
if err != nil { if err != nil {
c.ResponseError(err.Error()) c.ResponseError(err.Error())
return return

View File

@ -28,15 +28,21 @@ type Plan struct {
DisplayName string `xorm:"varchar(100)" json:"displayName"` DisplayName string `xorm:"varchar(100)" json:"displayName"`
Description string `xorm:"varchar(100)" json:"description"` Description string `xorm:"varchar(100)" json:"description"`
PricePerMonth float64 `json:"pricePerMonth"` PricePerMonth float64 `json:"pricePerMonth"`
PricePerYear float64 `json:"pricePerYear"` PricePerYear float64 `json:"pricePerYear"`
Currency string `xorm:"varchar(100)" json:"currency"` Currency string `xorm:"varchar(100)" json:"currency"`
IsEnabled bool `json:"isEnabled"` Product string `json:"product"` // related product id
PaymentProviders []string `xorm:"varchar(100)" json:"paymentProviders"` // payment providers for related product
IsEnabled bool `json:"isEnabled"`
Role string `xorm:"varchar(100)" json:"role"` Role string `xorm:"varchar(100)" json:"role"`
Options []string `xorm:"-" json:"options"` Options []string `xorm:"-" json:"options"`
} }
func (plan *Plan) GetId() string {
return fmt.Sprintf("%s/%s", plan.Owner, plan.Name)
}
func GetPlanCount(owner, field, value string) (int64, error) { func GetPlanCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "") session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Plan{}) return session.Count(&Plan{})
@ -114,38 +120,3 @@ func DeletePlan(plan *Plan) (bool, error) {
} }
return affected != 0, nil return affected != 0, nil
} }
func (plan *Plan) GetId() string {
return fmt.Sprintf("%s/%s", plan.Owner, plan.Name)
}
func Subscribe(owner string, user string, plan string, pricing string) (*Subscription, error) {
selectedPricing, err := GetPricing(fmt.Sprintf("%s/%s", owner, pricing))
if err != nil {
return nil, err
}
valid := selectedPricing != nil && selectedPricing.IsEnabled
if !valid {
return nil, nil
}
planBelongToPricing, err := selectedPricing.HasPlan(owner, plan)
if err != nil {
return nil, err
}
if planBelongToPricing {
newSubscription := NewSubscription(owner, user, plan, selectedPricing.TrialDuration)
affected, err := AddSubscription(newSubscription)
if err != nil {
return nil, err
}
if affected {
return newSubscription, nil
}
}
return nil, nil
}

View File

@ -16,7 +16,6 @@ package object
import ( import (
"fmt" "fmt"
"strings"
"github.com/casdoor/casdoor/util" "github.com/casdoor/casdoor/util"
"github.com/xorm-io/core" "github.com/xorm-io/core"
@ -41,6 +40,26 @@ type Pricing struct {
State string `xorm:"varchar(100)" json:"state"` State string `xorm:"varchar(100)" json:"state"`
} }
func (pricing *Pricing) GetId() string {
return fmt.Sprintf("%s/%s", pricing.Owner, pricing.Name)
}
func (pricing *Pricing) HasPlan(planName string) (bool, error) {
planId := util.GetId(pricing.Owner, planName)
plan, err := GetPlan(planId)
if err != nil {
return false, err
}
if plan == nil {
return false, fmt.Errorf("plan: %s does not exist", planId)
}
if util.InSlice(pricing.Plans, plan.Name) {
return true, nil
}
return false, nil
}
func GetPricingCount(owner, field, value string) (int64, error) { func GetPricingCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "") session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Pricing{}) return session.Count(&Pricing{})
@ -74,7 +93,7 @@ func getPricing(owner, name string) (*Pricing, error) {
pricing := Pricing{Owner: owner, Name: name} pricing := Pricing{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&pricing) existed, err := ormer.Engine.Get(&pricing)
if err != nil { if err != nil {
return &pricing, err return nil, err
} }
if existed { if existed {
return &pricing, nil return &pricing, nil
@ -88,6 +107,20 @@ func GetPricing(id string) (*Pricing, error) {
return getPricing(owner, name) return getPricing(owner, name)
} }
func GetApplicationDefaultPricing(owner, appName string) (*Pricing, error) {
pricings := make([]*Pricing, 0, 1)
err := ormer.Engine.Asc("created_time").Find(&pricings, &Pricing{Owner: owner, Application: appName})
if err != nil {
return nil, err
}
for _, pricing := range pricings {
if pricing.IsEnabled {
return pricing, nil
}
}
return nil, nil
}
func UpdatePricing(id string, pricing *Pricing) (bool, error) { func UpdatePricing(id string, pricing *Pricing) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id) owner, name := util.GetOwnerAndNameFromId(id)
if p, err := getPricing(owner, name); err != nil { if p, err := getPricing(owner, name); err != nil {
@ -120,28 +153,21 @@ func DeletePricing(pricing *Pricing) (bool, error) {
return affected != 0, nil return affected != 0, nil
} }
func (pricing *Pricing) GetId() string { func CheckPricingAndPlan(owner, pricingName, planName string) error {
return fmt.Sprintf("%s/%s", pricing.Owner, pricing.Name) pricingId := util.GetId(owner, pricingName)
} pricing, err := GetPricing(pricingId)
if pricing == nil || err != nil {
func (pricing *Pricing) HasPlan(owner string, plan string) (bool, error) { if pricing == nil && err == nil {
selectedPlan, err := GetPlan(fmt.Sprintf("%s/%s", owner, plan)) err = fmt.Errorf("pricing: %s does not exist", pricingName)
if err != nil {
return false, err
}
if selectedPlan == nil {
return false, nil
}
result := false
for _, pricingPlan := range pricing.Plans {
if strings.Contains(pricingPlan, selectedPlan.Name) {
result = true
break
} }
return err
} }
ok, err := pricing.HasPlan(planName)
return result, nil if err != nil {
return err
}
if !ok {
return fmt.Errorf("pricing: %s does not have plan: %s", pricingName, planName)
}
return nil
} }

View File

@ -141,24 +141,24 @@ func (product *Product) isValidProvider(provider *Provider) bool {
return false return false
} }
func (product *Product) getProvider(providerId string) (*Provider, error) { func (product *Product) getProvider(providerName string) (*Provider, error) {
provider, err := getProvider(product.Owner, providerId) provider, err := getProvider(product.Owner, providerName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if provider == nil { if provider == nil {
return nil, fmt.Errorf("the payment provider: %s does not exist", providerId) return nil, fmt.Errorf("the payment provider: %s does not exist", providerName)
} }
if !product.isValidProvider(provider) { if !product.isValidProvider(provider) {
return nil, fmt.Errorf("the payment provider: %s is not valid for the product: %s", providerId, product.Name) return nil, fmt.Errorf("the payment provider: %s is not valid for the product: %s", providerName, product.Name)
} }
return provider, nil return provider, nil
} }
func BuyProduct(id string, providerName string, user *User, host string) (string, string, error) { func BuyProduct(id string, user *User, providerName, pricingName, planName, host string) (string, string, error) {
product, err := GetProduct(id) product, err := GetProduct(id)
if err != nil { if err != nil {
return "", "", err return "", "", err
@ -181,13 +181,24 @@ func BuyProduct(id string, providerName string, user *User, host string) (string
owner := product.Owner owner := product.Owner
productName := product.Name productName := product.Name
payerName := fmt.Sprintf("%s | %s", user.Name, user.DisplayName) payerName := fmt.Sprintf("%s | %s", user.Name, user.DisplayName)
paymentName := util.GenerateTimeId() paymentName := fmt.Sprintf("payment_%v", util.GenerateTimeId())
productDisplayName := product.DisplayName productDisplayName := product.DisplayName
originFrontend, originBackend := getOriginFromHost(host) originFrontend, originBackend := getOriginFromHost(host)
returnUrl := fmt.Sprintf("%s/payments/%s/%s/result", originFrontend, owner, paymentName) returnUrl := fmt.Sprintf("%s/payments/%s/%s/result", originFrontend, owner, paymentName)
notifyUrl := fmt.Sprintf("%s/api/notify-payment/%s/%s", originBackend, owner, paymentName) notifyUrl := fmt.Sprintf("%s/api/notify-payment/%s/%s", originBackend, owner, paymentName)
// Create an Order and get the payUrl if user.Type == "paid-user" {
// Create a subscription for `paid-user`
if pricingName != "" && planName != "" {
sub := NewSubscription(owner, user.Name, pricingName, planName, paymentName)
_, err := AddSubscription(sub)
if err != nil {
return "", "", err
}
returnUrl = fmt.Sprintf("%s/buy-plan/%s/%s/result?subscription=%s", originFrontend, owner, pricingName, sub.Name)
}
}
// Create an OrderId and get the payUrl
payUrl, orderId, err := pProvider.Pay(providerName, productName, payerName, paymentName, productDisplayName, product.Price, product.Currency, returnUrl, notifyUrl) payUrl, orderId, err := pProvider.Pay(providerName, productName, payerName, paymentName, productDisplayName, product.Price, product.Currency, returnUrl, notifyUrl)
if err != nil { if err != nil {
return "", "", err return "", "", err
@ -228,7 +239,6 @@ func BuyProduct(id string, providerName string, user *User, host string) (string
if !affected { if !affected {
return "", "", fmt.Errorf("failed to add payment: %s", util.StructToJson(payment)) return "", "", fmt.Errorf("failed to add payment: %s", util.StructToJson(payment))
} }
return payUrl, orderId, err return payUrl, orderId, err
} }
@ -252,3 +262,34 @@ func ExtendProductWithProviders(product *Product) error {
return nil return nil
} }
func CreateProductForPlan(plan *Plan) *Product {
product := &Product{
Owner: plan.Owner,
Name: fmt.Sprintf("product_%v", util.GetRandomName()),
DisplayName: fmt.Sprintf("Auto Created Product for Plan %v(%v)", plan.GetId(), plan.DisplayName),
CreatedTime: plan.CreatedTime,
Image: "https://cdn.casbin.org/img/casdoor-logo_1185x256.png", // TODO
Detail: fmt.Sprintf("This Product was auto created for Plan %v(%v)", plan.GetId(), plan.DisplayName),
Description: plan.Description,
Tag: "auto_created_product_for_plan",
Price: plan.PricePerMonth, // TODO
Currency: plan.Currency,
Quantity: 999,
Sold: 0,
Providers: plan.PaymentProviders,
State: "Published",
}
return product
}
func UpdateProductForPlan(plan *Plan, product *Product) {
product.DisplayName = fmt.Sprintf("Auto Created Product for Plan %v(%v)", plan.GetId(), plan.DisplayName)
product.Detail = fmt.Sprintf("This Product was auto created for Plan %v(%v)", plan.GetId(), plan.DisplayName)
product.Price = plan.PricePerMonth // TODO
product.Providers = plan.PaymentProviders
product.Currency = plan.Currency
}

View File

@ -18,47 +18,108 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/casdoor/casdoor/pp"
"github.com/casdoor/casdoor/util" "github.com/casdoor/casdoor/util"
"github.com/xorm-io/core" "github.com/xorm-io/core"
) )
const defaultStatus = "Pending" type SubscriptionState string
const (
SubStatePending SubscriptionState = "Pending"
SubStateError SubscriptionState = "Error"
SubStateSuspended SubscriptionState = "Suspended" // suspended by the admin
SubStateActive SubscriptionState = "Active"
SubStateUpcoming SubscriptionState = "Upcoming"
SubStateExpired SubscriptionState = "Expired"
)
type Subscription struct { type Subscription struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"` Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"` Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"` DisplayName string `xorm:"varchar(100)" json:"displayName"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
Description string `xorm:"varchar(100)" json:"description"`
StartDate time.Time `json:"startDate"` User string `xorm:"varchar(100)" json:"user"`
EndDate time.Time `json:"endDate"` Pricing string `xorm:"varchar(100)" json:"pricing"`
Duration int `json:"duration"` Plan string `xorm:"varchar(100)" json:"plan"`
Description string `xorm:"varchar(100)" json:"description"` Payment string `xorm:"varchar(100)" json:"payment"`
User string `xorm:"mediumtext" json:"user"` StartTime time.Time `json:"startTime"`
Plan string `xorm:"varchar(100)" json:"plan"` EndTime time.Time `json:"endTime"`
Duration int `json:"duration"`
IsEnabled bool `json:"isEnabled"` State SubscriptionState `xorm:"varchar(100)" json:"state"`
Submitter string `xorm:"varchar(100)" json:"submitter"`
Approver string `xorm:"varchar(100)" json:"approver"`
ApproveTime string `xorm:"varchar(100)" json:"approveTime"`
State string `xorm:"varchar(100)" json:"state"`
} }
func NewSubscription(owner string, user string, plan string, duration int) *Subscription { func (sub *Subscription) GetId() string {
return fmt.Sprintf("%s/%s", sub.Owner, sub.Name)
}
func (sub *Subscription) UpdateState() error {
preState := sub.State
// update subscription state by payment state
if sub.State == SubStatePending {
if sub.Payment == "" {
return nil
}
payment, err := GetPayment(util.GetId(sub.Owner, sub.Payment))
if err != nil {
return err
}
if payment == nil {
sub.Description = fmt.Sprintf("payment: %s does not exist", sub.Payment)
sub.State = SubStateError
} else {
if payment.State == pp.PaymentStatePaid {
sub.State = SubStateActive
} else if payment.State != pp.PaymentStateCreated {
// other states: Canceled, Timeout, Error
sub.Description = fmt.Sprintf("payment: %s state is %v", sub.Payment, payment.State)
sub.State = SubStateError
}
}
}
if sub.State == SubStateActive || sub.State == SubStateUpcoming || sub.State == SubStateExpired {
if sub.EndTime.Before(time.Now()) {
sub.State = SubStateExpired
} else if sub.StartTime.After(time.Now()) {
sub.State = SubStateUpcoming
} else {
sub.State = SubStateActive
}
}
if preState != sub.State {
_, err := UpdateSubscription(sub.GetId(), sub)
if err != nil {
return err
}
}
return nil
}
func NewSubscription(owner, userName, pricingName, planName, paymentName string) *Subscription {
id := util.GenerateId()[:6] id := util.GenerateId()[:6]
return &Subscription{ return &Subscription{
Name: "Subscription_" + id,
DisplayName: "New Subscription - " + id,
Owner: owner, Owner: owner,
User: owner + "/" + user, Name: "sub_" + id,
Plan: owner + "/" + plan, DisplayName: "New Subscription - " + id,
CreatedTime: util.GetCurrentTime(), CreatedTime: util.GetCurrentTime(),
State: defaultStatus,
Duration: duration, User: userName,
StartDate: time.Now(), Pricing: pricingName,
EndDate: time.Now().AddDate(0, 0, duration), Plan: planName,
Payment: paymentName,
StartTime: time.Now(),
EndTime: time.Now().AddDate(0, 0, 30),
Duration: 30, // TODO
State: SubStatePending, // waiting for payment complete
} }
} }
@ -73,7 +134,28 @@ func GetSubscriptions(owner string) ([]*Subscription, error) {
if err != nil { if err != nil {
return subscriptions, err return subscriptions, err
} }
for _, sub := range subscriptions {
err = sub.UpdateState()
if err != nil {
return nil, err
}
}
return subscriptions, nil
}
func GetSubscriptionsByUser(owner, userName string) ([]*Subscription, error) {
subscriptions := []*Subscription{}
err := ormer.Engine.Desc("created_time").Find(&subscriptions, &Subscription{Owner: owner, User: userName})
if err != nil {
return subscriptions, err
}
// update subscription state
for _, sub := range subscriptions {
err = sub.UpdateState()
if err != nil {
return subscriptions, err
}
}
return subscriptions, nil return subscriptions, nil
} }
@ -84,7 +166,12 @@ func GetPaginationSubscriptions(owner string, offset, limit int, field, value, s
if err != nil { if err != nil {
return subscriptions, err return subscriptions, err
} }
for _, sub := range subscriptions {
err = sub.UpdateState()
if err != nil {
return nil, err
}
}
return subscriptions, nil return subscriptions, nil
} }
@ -144,7 +231,3 @@ func DeleteSubscription(subscription *Subscription) (bool, error) {
return affected != 0, nil return affected != 0, nil
} }
func (subscription *Subscription) GetId() string {
return fmt.Sprintf("%s/%s", subscription.Owner, subscription.Name)
}

View File

@ -541,7 +541,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
} }
} }
if isAdmin { if isAdmin {
columns = append(columns, "name", "email", "phone", "country_code") columns = append(columns, "name", "email", "phone", "country_code", "type")
} }
if util.ContainsString(columns, "groups") { if util.ContainsString(columns, "groups") {

View File

@ -114,8 +114,8 @@ func (pp *PaypalPaymentProvider) Notify(request *http.Request, body []byte, auth
if err != nil { if err != nil {
return nil, err return nil, err
} }
if captureRsp.Code != paypal.Success { if detailRsp.Code != paypal.Success {
errDetail := captureRsp.ErrorResponse.Details[0] errDetail := detailRsp.ErrorResponse.Details[0]
switch errDetail.Issue { switch errDetail.Issue {
case "ORDER_NOT_APPROVED": case "ORDER_NOT_APPROVED":
notifyResult.PaymentStatus = PaymentStateCanceled notifyResult.PaymentStatus = PaymentStateCanceled

View File

@ -657,7 +657,8 @@ class App extends Component {
window.location.pathname.startsWith("/result") || window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") || window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/auto-signup") || window.location.pathname.startsWith("/auto-signup") ||
window.location.pathname.startsWith("/select-plan"); window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan");
} }
renderPage() { renderPage() {

View File

@ -29,6 +29,8 @@ import PromptPage from "./auth/PromptPage";
import ResultPage from "./auth/ResultPage"; import ResultPage from "./auth/ResultPage";
import CasLogout from "./auth/CasLogout"; import CasLogout from "./auth/CasLogout";
import {authConfig} from "./auth/Auth"; import {authConfig} from "./auth/Auth";
import ProductBuyPage from "./ProductBuyPage";
import PaymentResultPage from "./PaymentResultPage";
class EntryPage extends React.Component { class EntryPage extends React.Component {
constructor(props) { constructor(props) {
@ -108,7 +110,9 @@ class EntryPage extends React.Component {
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} /> <Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} /> <Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/login" render={(props) => {return (<LoginPage {...this.props} application={this.state.application} type={"cas"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />);}} /> <Route exact path="/cas/:owner/:casApplicationName/login" render={(props) => {return (<LoginPage {...this.props} application={this.state.application} type={"cas"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />);}} />
<Route exact path="/select-plan/:owner/:pricingName" render={(props) => this.renderHomeIfLoggedIn(<PricingPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />)} /> <Route exact path="/select-plan/:owner/:pricingName" render={(props) => <PricingPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />} />
<Route exact path="/buy-plan/:owner/:pricingName" render={(props) => <ProductBuyPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />} />
<Route exact path="/buy-plan/:owner/:pricingName/result" render={(props) => <PaymentResultPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />} />
</Switch> </Switch>
</div> </div>
); );

View File

@ -158,14 +158,6 @@ class PaymentListPage extends BaseListPage {
return Setting.getFormattedDate(text); return Setting.getFormattedDate(text);
}, },
}, },
// {
// title: i18next.t("general:Display name"),
// dataIndex: 'displayName',
// key: 'displayName',
// width: '160px',
// sorter: true,
// ...this.getColumnSearchProps('displayName'),
// },
{ {
title: i18next.t("provider:Type"), title: i18next.t("provider:Type"),
dataIndex: "type", dataIndex: "type",
@ -187,6 +179,13 @@ class PaymentListPage extends BaseListPage {
// width: '160px', // width: '160px',
sorter: true, sorter: true,
...this.getColumnSearchProps("productDisplayName"), ...this.getColumnSearchProps("productDisplayName"),
render: (text, record, index) => {
return (
<Link to={`/products/${record.owner}/${record.productName}`}>
{text}
</Link>
);
},
}, },
{ {
title: i18next.t("product:Price"), title: i18next.t("product:Price"),

View File

@ -15,17 +15,24 @@
import React from "react"; import React from "react";
import {Button, Result, Spin} from "antd"; import {Button, Result, Spin} from "antd";
import * as PaymentBackend from "./backend/PaymentBackend"; import * as PaymentBackend from "./backend/PaymentBackend";
import * as PricingBackend from "./backend/PricingBackend";
import * as SubscriptionBackend from "./backend/SubscriptionBackend";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import i18next from "i18next"; import i18next from "i18next";
class PaymentResultPage extends React.Component { class PaymentResultPage extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const params = new URLSearchParams(window.location.search);
this.state = { this.state = {
classes: props, classes: props,
paymentName: props.match.params.paymentName, owner: props.match?.params?.organizationName ?? props.match?.params?.owner ?? null,
organizationName: props.match.params.organizationName, paymentName: props.match?.params?.paymentName ?? null,
pricingName: props.pricingName ?? props.match?.params?.pricingName ?? null,
subscriptionName: params.get("subscription"),
payment: null, payment: null,
pricing: props.pricing ?? null,
subscription: props.subscription ?? null,
timeout: null, timeout: null,
}; };
} }
@ -40,28 +47,77 @@ class PaymentResultPage extends React.Component {
} }
} }
getPayment() { setStateAsync(state) {
PaymentBackend.getPayment(this.state.organizationName, this.state.paymentName) return new Promise((resolve, reject) => {
.then((res) => { this.setState(state, () => {
this.setState({ resolve();
payment: res.data,
});
// window.console.log("payment=", res.data);
if (res.data.state === "Created") {
if (["PayPal", "Stripe"].includes(res.data.type)) {
this.setState({
timeout: setTimeout(() => {
PaymentBackend.notifyPayment(this.state.organizationName, this.state.paymentName)
.then((res) => {
this.getPayment();
});
}, 1000),
});
} else {
this.setState({timeout: setTimeout(() => this.getPayment(), 1000)});
}
}
}); });
});
}
onUpdatePricing(pricing) {
this.props.onUpdatePricing(pricing);
}
async getPayment() {
if (!(this.state.owner && (this.state.paymentName || (this.state.pricingName && this.state.subscriptionName)))) {
return ;
}
try {
// loading price & subscription
if (this.state.pricingName && this.state.subscriptionName) {
if (!this.state.pricing) {
const res = await PricingBackend.getPricing(this.state.owner, this.state.pricingName);
if (res.status !== "ok") {
throw new Error(res.msg);
}
const pricing = res.data;
await this.setStateAsync({
pricing: pricing,
});
}
if (!this.state.subscription) {
const res = await SubscriptionBackend.getSubscription(this.state.owner, this.state.subscriptionName);
if (res.status !== "ok") {
throw new Error(res.msg);
}
const subscription = res.data;
await this.setStateAsync({
subscription: subscription,
});
}
const paymentName = this.state.subscription.payment;
await this.setStateAsync({
paymentName: paymentName,
});
this.onUpdatePricing(this.state.pricing);
}
const res = await PaymentBackend.getPayment(this.state.owner, this.state.paymentName);
if (res.status !== "ok") {
throw new Error(res.msg);
}
const payment = res.data;
await this.setStateAsync({
payment: payment,
});
if (payment.state === "Created") {
if (["PayPal", "Stripe"].includes(payment.type)) {
this.setState({
timeout: setTimeout(async() => {
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);
this.getPayment();
}, 1000),
});
} else {
this.setState({
timeout: setTimeout(() => this.getPayment(), 1000),
});
}
}
} catch (err) {
Setting.showMessage("error", err.message);
return;
}
} }
goToPaymentUrl(payment) { goToPaymentUrl(payment) {
@ -81,7 +137,7 @@ class PaymentResultPage extends React.Component {
if (payment.state === "Paid") { if (payment.state === "Paid") {
return ( return (
<div> <div className="login-content">
{ {
Setting.renderHelmet(payment) Setting.renderHelmet(payment)
} }
@ -101,7 +157,7 @@ class PaymentResultPage extends React.Component {
); );
} else if (payment.state === "Created") { } else if (payment.state === "Created") {
return ( return (
<div> <div className="login-content">
{ {
Setting.renderHelmet(payment) Setting.renderHelmet(payment)
} }
@ -117,7 +173,7 @@ class PaymentResultPage extends React.Component {
); );
} else if (payment.state === "Canceled") { } else if (payment.state === "Canceled") {
return ( return (
<div> <div className="login-content">
{ {
Setting.renderHelmet(payment) Setting.renderHelmet(payment)
} }
@ -137,7 +193,7 @@ class PaymentResultPage extends React.Component {
); );
} else if (payment.state === "Timeout") { } else if (payment.state === "Timeout") {
return ( return (
<div> <div className="login-content">
{ {
Setting.renderHelmet(payment) Setting.renderHelmet(payment)
} }
@ -157,7 +213,7 @@ class PaymentResultPage extends React.Component {
); );
} else { } else {
return ( return (
<div> <div className="login-content">
{ {
Setting.renderHelmet(payment) Setting.renderHelmet(payment)
} }

View File

@ -18,6 +18,7 @@ import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as RoleBackend from "./backend/RoleBackend"; import * as RoleBackend from "./backend/RoleBackend";
import * as PlanBackend from "./backend/PlanBackend"; import * as PlanBackend from "./backend/PlanBackend";
import * as UserBackend from "./backend/UserBackend"; import * as UserBackend from "./backend/UserBackend";
import * as ProviderBackend from "./backend/ProviderBackend";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import i18next from "i18next"; import i18next from "i18next";
@ -28,14 +29,14 @@ class PlanEditPage extends React.Component {
super(props); super(props);
this.state = { this.state = {
classes: props, classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName, organizationName: props?.organizationName ?? props?.match?.params?.organizationName ?? null,
planName: props.match.params.planName, planName: props?.match?.params?.planName ?? null,
plan: null, plan: null,
organizations: [], organizations: [],
users: [], users: [],
roles: [], roles: [],
providers: [], paymentProviders: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit", mode: props?.location?.mode ?? "edit",
}; };
} }
@ -58,6 +59,7 @@ class PlanEditPage extends React.Component {
this.getUsers(this.state.organizationName); this.getUsers(this.state.organizationName);
this.getRoles(this.state.organizationName); this.getRoles(this.state.organizationName);
this.getPaymentProviders(this.state.organizationName);
}); });
} }
@ -89,6 +91,20 @@ class PlanEditPage extends React.Component {
}); });
} }
getPaymentProviders(organizationName) {
ProviderBackend.getProviders(organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
paymentProviders: res.data.filter(provider => provider.category === "Payment"),
});
return;
}
Setting.showMessage("error", res.msg);
});
}
getOrganizations() { getOrganizations() {
OrganizationBackend.getOrganizations("admin") OrganizationBackend.getOrganizations("admin")
.then((res) => { .then((res) => {
@ -165,7 +181,7 @@ class PlanEditPage extends React.Component {
</Col> </Col>
<Col span={22} > <Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.role} onChange={(value => {this.updatePlanField("role", value);})} <Select virtual={false} style={{width: "100%"}} value={this.state.plan.role} onChange={(value => {this.updatePlanField("role", value);})}
options={this.state.roles.map((role) => Setting.getOption(`${role.owner}/${role.name}`, `${role.owner}/${role.name}`)) options={this.state.roles.map((role) => Setting.getOption(role.name, role.name))
} /> } />
</Col> </Col>
</Row> </Row>
@ -216,6 +232,18 @@ class PlanEditPage extends React.Component {
</Select> </Select>
</Col> </Col>
</Row> </Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("product:Payment providers"), i18next.t("product:Payment providers - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} mode="multiple" style={{width: "100%"}} value={this.state.plan.paymentProviders ?? []} onChange={(value => {this.updatePlanField("paymentProviders", value);})}>
{
this.state.paymentProviders.map((provider, index) => <Option key={index} value={provider.name}>{provider.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} : {Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :

View File

@ -126,6 +126,14 @@ class PlanListPage extends BaseListPage {
sorter: true, sorter: true,
...this.getColumnSearchProps("displayName"), ...this.getColumnSearchProps("displayName"),
}, },
{
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
},
{ {
title: i18next.t("plan:Price per month"), title: i18next.t("plan:Price per month"),
dataIndex: "pricePerMonth", dataIndex: "pricePerMonth",
@ -148,7 +156,21 @@ class PlanListPage extends BaseListPage {
...this.getColumnSearchProps("role"), ...this.getColumnSearchProps("role"),
render: (text, record, index) => { render: (text, record, index) => {
return ( return (
<Link to={`/roles/${encodeURIComponent(text)}`}> <Link to={`/roles/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("plan:Related product"),
dataIndex: "product",
key: "product",
width: "130px",
...this.getColumnSearchProps("product"),
render: (text, record, index) => {
return (
<Link to={`/products/${record.owner}/${text}`}>
{text} {text}
</Link> </Link>
); );

View File

@ -190,7 +190,7 @@ class PricingEditPage extends React.Component {
onChange={(value => { onChange={(value => {
this.updatePricingField("plans", value); this.updatePricingField("plans", value);
})} })}
options={this.state.plans.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))} options={this.state.plans.map((plan) => Setting.getOption(plan.name, plan.name))}
/> />
</Col> </Col>
</Row> </Row>
@ -294,7 +294,7 @@ class PricingEditPage extends React.Component {
</Button> </Button>
</Col> </Col>
<Col> <Col>
<PricingPage pricing={this.state.pricing}></PricingPage> <PricingPage pricing={this.state.pricing} owner={this.state.pricing.owner}></PricingPage>
</Col> </Col>
</React.Fragment> </React.Fragment>
); );

View File

@ -14,7 +14,8 @@
import React from "react"; import React from "react";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {Button, Switch, Table} from "antd"; import {Button, Col, Row, Switch, Table, Tooltip} from "antd";
import {EditOutlined} from "@ant-design/icons";
import moment from "moment"; import moment from "moment";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import * as PricingBackend from "./backend/PricingBackend"; import * as PricingBackend from "./backend/PricingBackend";
@ -118,11 +119,58 @@ class PricingListPage extends BaseListPage {
title: i18next.t("general:Display name"), title: i18next.t("general:Display name"),
dataIndex: "displayName", dataIndex: "displayName",
key: "displayName", key: "displayName",
// width: "170px", width: "170px",
sorter: true, sorter: true,
...this.getColumnSearchProps("displayName"), ...this.getColumnSearchProps("displayName"),
}, },
{
title: i18next.t("general:Application"),
dataIndex: "application",
key: "application",
width: "170px",
sorter: true,
...this.getColumnSearchProps("application"),
render: (text, record, index) => {
return (
<Link to={`/applications/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Plans"),
dataIndex: "plans",
key: "plans",
// width: "170px",
sorter: true,
...this.getColumnSearchProps("plans"),
render: (plans, record, index) => {
if (plans.length === 0) {
return `(${i18next.t("general:empty")})`;
}
return (
<div>
<Row>
{
plans.map((plan) => (
<Col key={plan}>
<div style={{display: "inline", marginRight: "20px"}}>
<Tooltip placement="topLeft" title="Edit">
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/plans/${record.owner}/${plan}`)} />
</Tooltip>
<Link to={`/plans/${record.owner}/${plan}`}>
{plan}
</Link>
</div>
</Col>
))
}
</Row>
</div>
);
},
},
{ {
title: i18next.t("general:Is enabled"), title: i18next.t("general:Is enabled"),
dataIndex: "isEnabled", dataIndex: "isEnabled",

View File

@ -17,16 +17,24 @@ import {Button, Descriptions, Modal, Spin} from "antd";
import {CheckCircleTwoTone} from "@ant-design/icons"; import {CheckCircleTwoTone} from "@ant-design/icons";
import i18next from "i18next"; import i18next from "i18next";
import * as ProductBackend from "./backend/ProductBackend"; import * as ProductBackend from "./backend/ProductBackend";
import * as PlanBackend from "./backend/PlanBackend";
import * as PricingBackend from "./backend/PricingBackend";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
class ProductBuyPage extends React.Component { class ProductBuyPage extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const params = new URLSearchParams(window.location.search);
this.state = { this.state = {
classes: props, classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props?.match?.params?.organizationName, owner: props?.organizationName ?? props?.match?.params?.organizationName ?? props?.match?.params?.owner ?? null,
productName: props.productName !== undefined ? props.productName : props?.match?.params?.productName, productName: props?.productName ?? props?.match?.params?.productName ?? null,
pricingName: props?.pricingName ?? props?.match?.params?.pricingName ?? null,
planName: params.get("plan"),
userName: params.get("user"),
product: null, product: null,
pricing: props?.pricing ?? null,
plan: null,
isPlacingOrder: false, isPlacingOrder: false,
qrCodeModalProvider: null, qrCodeModalProvider: null,
}; };
@ -36,20 +44,58 @@ class ProductBuyPage extends React.Component {
this.getProduct(); this.getProduct();
} }
getProduct() { setStateAsync(state) {
if (this.state.productName === undefined || this.state.organizationName === undefined) { return new Promise((resolve, reject) => {
this.setState(state, () => {
resolve();
});
});
}
onUpdatePricing(pricing) {
this.props.onUpdatePricing(pricing);
}
async getProduct() {
if (!this.state.owner || (!this.state.productName && !this.state.pricingName)) {
return ; return ;
} }
ProductBackend.getProduct(this.state.organizationName, this.state.productName) try {
.then((res) => { // load pricing & plan
if (res.status === "error") { if (this.state.pricingName) {
Setting.showMessage("error", res.msg); if (!this.state.planName || !this.state.userName) {
return; return ;
} }
this.setState({ let res = await PricingBackend.getPricing(this.state.owner, this.state.pricingName);
product: res.data, if (res.status !== "ok") {
throw new Error(res.msg);
}
const pricing = res.data;
res = await PlanBackend.getPlan(this.state.owner, this.state.planName);
if (res.status !== "ok") {
throw new Error(res.msg);
}
const plan = res.data;
const productName = plan.product;
await this.setStateAsync({
pricing: pricing,
plan: plan,
productName: productName,
}); });
this.onUpdatePricing(pricing);
}
// load product
const res = await ProductBackend.getProduct(this.state.owner, this.state.productName);
if (res.status !== "ok") {
throw new Error(res.msg);
}
this.setState({
product: res.data,
}); });
} catch (err) {
Setting.showMessage("error", err.message);
return;
}
} }
getProductObj() { getProductObj() {
@ -96,7 +142,7 @@ class ProductBuyPage extends React.Component {
isPlacingOrder: true, isPlacingOrder: true,
}); });
ProductBackend.buyProduct(product.owner, product.name, provider.name) ProductBackend.buyProduct(product.owner, product.name, provider.name, this.state.pricingName ?? "", this.state.planName ?? "", this.state.userName ?? "")
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
const payUrl = res.data; const payUrl = res.data;
@ -215,11 +261,11 @@ class ProductBuyPage extends React.Component {
} }
return ( return (
<div> <div className="login-content">
<Spin spinning={this.state.isPlacingOrder} size="large" tip={i18next.t("product:Placing order...")} style={{paddingTop: "10%"}} > <Spin spinning={this.state.isPlacingOrder} size="large" tip={i18next.t("product:Placing order...")} style={{paddingTop: "10%"}} >
<Descriptions title={i18next.t("product:Buy Product")} bordered> <Descriptions title={<span style={{fontSize: 28}}>{i18next.t("product:Buy Product")}</span>} bordered>
<Descriptions.Item label={i18next.t("general:Name")} span={3}> <Descriptions.Item label={i18next.t("general:Name")} span={3}>
<span style={{fontSize: 28}}> <span style={{fontSize: 25}}>
{Setting.getLanguageText(product?.displayName)} {Setting.getLanguageText(product?.displayName)}
</span> </span>
</Descriptions.Item> </Descriptions.Item>

View File

@ -1170,9 +1170,9 @@ export function getTags(tags, urlPrefix = null) {
return res; return res;
} }
export function getTag(color, text) { export function getTag(color, text, icon) {
return ( return (
<Tag color={color}> <Tag color={color} icon={icon}>
{text} {text}
</Tag> </Tag>
); );
@ -1254,3 +1254,13 @@ export function builtInObject(obj) {
} }
return obj.owner === "built-in" && BuiltInObjects.includes(obj.name); return obj.owner === "built-in" && BuiltInObjects.includes(obj.name);
} }
export function getCurrencySymbol(currency) {
if (currency === "USD" || currency === "usd") {
return "$";
} else if (currency === "CNY" || currency === "cny") {
return "¥";
} else {
return currency;
}
}

View File

@ -14,8 +14,9 @@
import moment from "moment"; import moment from "moment";
import React from "react"; import React from "react";
import {Button, Card, Col, DatePicker, Input, InputNumber, Row, Select, Switch} from "antd"; import {Button, Card, Col, DatePicker, Input, InputNumber, Row, Select} from "antd";
import * as OrganizationBackend from "./backend/OrganizationBackend"; import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as PricingBackend from "./backend/PricingBackend";
import * as PlanBackend from "./backend/PlanBackend"; import * as PlanBackend from "./backend/PlanBackend";
import * as SubscriptionBackend from "./backend/SubscriptionBackend"; import * as SubscriptionBackend from "./backend/SubscriptionBackend";
import * as UserBackend from "./backend/UserBackend"; import * as UserBackend from "./backend/UserBackend";
@ -33,7 +34,8 @@ class SubscriptionEditPage extends React.Component {
subscription: null, subscription: null,
organizations: [], organizations: [],
users: [], users: [],
planes: [], pricings: [],
plans: [],
providers: [], providers: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit", mode: props.location.mode !== undefined ? props.location.mode : "edit",
}; };
@ -62,15 +64,25 @@ class SubscriptionEditPage extends React.Component {
}); });
this.getUsers(this.state.organizationName); this.getUsers(this.state.organizationName);
this.getPlanes(this.state.organizationName); this.getPricings(this.state.organizationName);
this.getPlans(this.state.organizationName);
}); });
} }
getPlanes(organizationName) { getPricings(organizationName) {
PricingBackend.getPricings(organizationName)
.then((res) => {
this.setState({
pricings: res.data,
});
});
}
getPlans(organizationName) {
PlanBackend.getPlans(organizationName) PlanBackend.getPlans(organizationName)
.then((res) => { .then((res) => {
this.setState({ this.setState({
planes: res.data, plans: res.data,
}); });
}); });
} }
@ -133,7 +145,7 @@ class SubscriptionEditPage extends React.Component {
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.owner} onChange={(owner => { <Select virtual={false} style={{width: "100%"}} value={this.state.subscription.owner} onChange={(owner => {
this.updateSubscriptionField("owner", owner); this.updateSubscriptionField("owner", owner);
this.getUsers(owner); this.getUsers(owner);
this.getPlanes(owner); this.getPlans(owner);
})} })}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name)) options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} /> } />
@ -171,21 +183,21 @@ class SubscriptionEditPage extends React.Component {
</Row> </Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Start date"), i18next.t("subscription:Start date - Tooltip"))} {Setting.getLabel(i18next.t("subscription:Start time"), i18next.t("subscription:Start time - Tooltip"))}
</Col> </Col>
<Col span={22} > <Col span={22} >
<DatePicker value={dayjs(this.state.subscription.startDate)} onChange={value => { <DatePicker value={dayjs(this.state.subscription.startTime)} onChange={value => {
this.updateSubscriptionField("startDate", value); this.updateSubscriptionField("startTime", value);
}} /> }} />
</Col> </Col>
</Row> </Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:End date"), i18next.t("subscription:End date - Tooltip"))} {Setting.getLabel(i18next.t("subscription:End time"), i18next.t("subscription:End time - Tooltip"))}
</Col> </Col>
<Col span={22} > <Col span={22} >
<DatePicker value={dayjs(this.state.subscription.endDate)} onChange={value => { <DatePicker value={dayjs(this.state.subscription.endTime)} onChange={value => {
this.updateSubscriptionField("endDate", value); this.updateSubscriptionField("endTime", value);
}} /> }} />
</Col> </Col>
</Row> </Row>
@ -196,21 +208,42 @@ class SubscriptionEditPage extends React.Component {
<Col span={22} > <Col span={22} >
<Select style={{width: "100%"}} value={this.state.subscription.user} <Select style={{width: "100%"}} value={this.state.subscription.user}
onChange={(value => {this.updateSubscriptionField("user", value);})} onChange={(value => {this.updateSubscriptionField("user", value);})}
options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))} options={this.state.users.map((user) => Setting.getOption(user.name, user.name))}
/> />
</Col> </Col>
</Row> </Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Pricing"), i18next.t("general:Pricing - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.pricing}
onChange={(value => {this.updateSubscriptionField("pricing", value);})}
options={this.state.pricings.map((pricing) => Setting.getOption(pricing.name, pricing.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Plan"), i18next.t("general:Plan - Tooltip"))} : {Setting.getLabel(i18next.t("general:Plan"), i18next.t("general:Plan - Tooltip"))} :
</Col> </Col>
<Col span={22} > <Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.plan} onChange={(value => {this.updateSubscriptionField("plan", value);})} <Select virtual={false} style={{width: "100%"}} value={this.state.subscription.plan}
options={this.state.planes.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`)) onChange={(value => {this.updateSubscriptionField("plan", value);})}
options={this.state.plans.map((plan) => Setting.getOption(plan.name, plan.name))
} /> } />
</Col> </Col>
</Row> </Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Payment"), i18next.t("general:Payment - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.payment} disabled={true} onChange={e => {
this.updateSubscriptionField("payment", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} : {Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
@ -221,46 +254,6 @@ class SubscriptionEditPage extends React.Component {
}} /> }} />
</Col> </Col>
</Row> </Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.subscription.isEnabled} onChange={checked => {
this.updateSubscriptionField("isEnabled", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("permission:Submitter"), i18next.t("permission:Submitter - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.subscription.submitter} onChange={e => {
this.updateSubscriptionField("submitter", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("permission:Approver"), i18next.t("permission:Approver - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.subscription.approver} onChange={e => {
this.updateSubscriptionField("approver", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("permission:Approve time"), i18next.t("permission:Approve time - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={Setting.getFormattedDate(this.state.subscription.approveTime)} onChange={e => {
this.updatePermissionField("approveTime", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} : {Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} :
@ -280,8 +273,12 @@ class SubscriptionEditPage extends React.Component {
this.updateSubscriptionField("state", value); this.updateSubscriptionField("state", value);
})} })}
options={[ options={[
{value: "Approved", name: i18next.t("permission:Approved")},
{value: "Pending", name: i18next.t("permission:Pending")}, {value: "Pending", name: i18next.t("permission:Pending")},
{value: "Active", name: i18next.t("permission:Active")},
{value: "Upcoming", name: i18next.t("permission:Upcoming")},
{value: "Expired", name: i18next.t("permission:Expired")},
{value: "Error", name: i18next.t("permission:Error")},
{value: "Suspended", name: i18next.t("permission:Suspended")},
].map((item) => Setting.getOption(item.name, item.value))} ].map((item) => Setting.getOption(item.name, item.value))}
/> />
</Col> </Col>

View File

@ -15,6 +15,7 @@
import React from "react"; import React from "react";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import {Button, Table} from "antd"; import {Button, Table} from "antd";
import {ClockCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, MinusCircleOutlined, SyncOutlined} from "@ant-design/icons";
import moment from "moment"; import moment from "moment";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import * as SubscriptionBackend from "./backend/SubscriptionBackend"; import * as SubscriptionBackend from "./backend/SubscriptionBackend";
@ -26,24 +27,20 @@ class SubscriptionListPage extends BaseListPage {
newSubscription() { newSubscription() {
const randomName = Setting.getRandomName(); const randomName = Setting.getRandomName();
const owner = Setting.getRequestOrganization(this.props.account); const owner = Setting.getRequestOrganization(this.props.account);
const defaultDuration = 365; const defaultDuration = 30;
return { return {
owner: owner, owner: owner,
name: `subscription_${randomName}`, name: `sub_${randomName}`,
createdTime: moment().format(), createdTime: moment().format(),
displayName: `New Subscription - ${randomName}`, displayName: `New Subscription - ${randomName}`,
startDate: moment().format(), startTime: moment().format(),
endDate: moment().add(defaultDuration, "d").format(), endTime: moment().add(defaultDuration, "d").format(),
duration: defaultDuration, duration: defaultDuration,
description: "", description: "",
user: "", user: "",
plan: "", plan: "",
isEnabled: true, state: "Active",
submitter: this.props.account.name,
approver: this.props.account.name,
approveTime: moment().format(),
state: "Approved",
}; };
} }
@ -139,6 +136,34 @@ class SubscriptionListPage extends BaseListPage {
width: "140px", width: "140px",
...this.getColumnSearchProps("duration"), ...this.getColumnSearchProps("duration"),
}, },
{
title: i18next.t("subscription:Start time"),
dataIndex: "startTime",
key: "startTime",
width: "140px",
...this.getColumnSearchProps("startTime"),
},
{
title: i18next.t("subscription:End time"),
dataIndex: "endTime",
key: "endTime",
width: "140px",
...this.getColumnSearchProps("endTime"),
},
{
title: i18next.t("general:Pricing"),
dataIndex: "pricing",
key: "pricing",
width: "140px",
...this.getColumnSearchProps("pricing"),
render: (text, record, index) => {
return (
<Link to={`/pricings/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{ {
title: i18next.t("general:Plan"), title: i18next.t("general:Plan"),
dataIndex: "plan", dataIndex: "plan",
@ -147,7 +172,7 @@ class SubscriptionListPage extends BaseListPage {
...this.getColumnSearchProps("plan"), ...this.getColumnSearchProps("plan"),
render: (text, record, index) => { render: (text, record, index) => {
return ( return (
<Link to={`/plans/${text}`}> <Link to={`/plans/${record.owner}/${text}`}>
{text} {text}
</Link> </Link>
); );
@ -161,7 +186,21 @@ class SubscriptionListPage extends BaseListPage {
...this.getColumnSearchProps("user"), ...this.getColumnSearchProps("user"),
render: (text, record, index) => { render: (text, record, index) => {
return ( return (
<Link to={`/users/${text}`}> <Link to={`/users/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Payment"),
dataIndex: "payment",
key: "payment",
width: "140px",
...this.getColumnSearchProps("payment"),
render: (text, record, index) => {
return (
<Link to={`/payments/${record.owner}/${text}`}>
{text} {text}
</Link> </Link>
); );
@ -176,10 +215,18 @@ class SubscriptionListPage extends BaseListPage {
...this.getColumnSearchProps("state"), ...this.getColumnSearchProps("state"),
render: (text, record, index) => { render: (text, record, index) => {
switch (text) { switch (text) {
case "Approved":
return Setting.getTag("success", i18next.t("permission:Approved"));
case "Pending": case "Pending":
return Setting.getTag("error", i18next.t("permission:Pending")); return Setting.getTag("processing", i18next.t("permission:Pending"), <ExclamationCircleOutlined />);
case "Active":
return Setting.getTag("success", i18next.t("permission:Active"), <SyncOutlined spin />);
case "Upcoming":
return Setting.getTag("warning", i18next.t("permission:Upcoming"), <ClockCircleOutlined />);
case "Expired":
return Setting.getTag("warning", i18next.t("permission:Expired"), <ClockCircleOutlined />);
case "Error":
return Setting.getTag("error", i18next.t("permission:Error"), <CloseCircleOutlined />);
case "Suspended":
return Setting.getTag("default", i18next.t("permission:Suspended"), <MinusCircleOutlined />);
default: default:
return null; return null;
} }

View File

@ -390,7 +390,7 @@ class UserEditPage extends React.Component {
</Col> </Col>
<Col span={22} > <Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.user.type} onChange={(value => {this.updateUserField("type", value);})} <Select virtual={false} style={{width: "100%"}} value={this.state.user.type} onChange={(value => {this.updateUserField("type", value);})}
options={["normal-user"].map(item => Setting.getOption(item, item))} options={["normal-user", "paid-user"].map(item => Setting.getOption(item, item))}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -403,6 +403,14 @@ class LoginPage extends React.Component {
/>); />);
}, },
}); });
} else if (res.data === "SelectPlan") {
// paid-user does not have active or pending subscription, go to application default pricing page to select-plan
const pricing = res.data2;
Setting.goToLink(`/select-plan/${pricing.owner}/${pricing.name}?user=${values.username}`);
} else if (res.data === "BuyPlanResult") {
// paid-user has pending subscription, go to buy-plan/result apge to notify payment result
const sub = res.data2;
Setting.goToLink(`/buy-plan/${sub.owner}/${sub.pricing}/result?subscription=${sub.name}`);
} else { } else {
callback(res); callback(res);
} }

View File

@ -133,7 +133,11 @@ class SignupPage extends React.Component {
}); });
} }
getResultPath(application) { getResultPath(application, signupParams) {
if (signupParams?.plan && signupParams?.pricing) {
// the prompt page needs the user to be signed in, so for paid-user sign up, just go to buy-plan page
return `/buy-plan/${application.organization}/${signupParams?.pricing}?user=${signupParams.username}&plan=${signupParams.plan}`;
}
if (authConfig.appName === application.name) { if (authConfig.appName === application.name) {
return "/result"; return "/result";
} else { } else {
@ -173,13 +177,13 @@ class SignupPage extends React.Component {
const application = this.getApplicationObj(); const application = this.getApplicationObj();
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
values["plan"] = params.get("plan"); values.plan = params.get("plan");
values["pricing"] = params.get("pricing"); values.pricing = params.get("pricing");
AuthBackend.signup(values) AuthBackend.signup(values)
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
if (Setting.hasPromptPage(application)) { if (Setting.hasPromptPage(application) && (!values.plan || !values.pricing)) {
AuthBackend.getAccount("") AuthBackend.getAccount("")
.then((res) => { .then((res) => {
let account = null; let account = null;
@ -188,13 +192,13 @@ class SignupPage extends React.Component {
account.organization = res.data2; account.organization = res.data2;
this.onUpdateAccount(account); this.onUpdateAccount(account);
Setting.goToLinkSoft(this, this.getResultPath(application)); Setting.goToLinkSoft(this, this.getResultPath(application, values));
} else { } else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`); Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
} }
}); });
} else { } else {
Setting.goToLinkSoft(this, this.getResultPath(application)); Setting.goToLinkSoft(this, this.getResultPath(application, values));
} }
} else { } else {
Setting.showMessage("error", i18next.t(`signup:${res.msg}`)); Setting.showMessage("error", i18next.t(`signup:${res.msg}`));

View File

@ -24,18 +24,8 @@ export function getPlans(owner, page = "", pageSize = "", field = "", value = ""
}).then(res => res.json()); }).then(res => res.json());
} }
export function getPlanById(id, includeOption = false) { export function getPlan(owner, name, includeOption = false) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${id}&includeOption=${includeOption}`, { return fetch(`${Setting.ServerUrl}/api/get-plan?id=${owner}/${encodeURIComponent(name)}&includeOption=${includeOption}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPlan(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { headers: {

View File

@ -70,8 +70,8 @@ export function deleteProduct(product) {
}).then(res => res.json()); }).then(res => res.json());
} }
export function buyProduct(owner, name, providerId) { export function buyProduct(owner, name, providerName, pricingName = "", planName = "", userName = "") {
return fetch(`${Setting.ServerUrl}/api/buy-product?id=${owner}/${encodeURIComponent(name)}&providerName=${providerId}`, { return fetch(`${Setting.ServerUrl}/api/buy-product?id=${owner}/${encodeURIComponent(name)}&providerName=${providerName}&pricingName=${pricingName}&planName=${planName}&userName=${userName}`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: {

View File

@ -24,11 +24,13 @@ import i18next from "i18next";
class PricingPage extends React.Component { class PricingPage extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const params = new URLSearchParams(window.location.search);
this.state = { this.state = {
classes: props, classes: props,
applications: null, applications: null,
owner: props.owner ?? (props.match?.params?.owner ?? null), owner: props.owner ?? (props.match?.params?.owner ?? null),
pricingName: (props.pricingName ?? props.match?.params?.pricingName) ?? null, pricingName: (props.pricingName ?? props.match?.params?.pricingName) ?? null,
userName: params.get("user"),
pricing: props.pricing, pricing: props.pricing,
plans: null, plans: null,
loading: false, loading: false,
@ -39,7 +41,9 @@ class PricingPage extends React.Component {
this.setState({ this.setState({
applications: [], applications: [],
}); });
if (this.state.userName) {
Setting.showMessage("info", `${i18next.t("pricing:paid-user do not have active subscription or pending subscription, please select a plan to buy")}`);
}
if (this.state.pricing) { if (this.state.pricing) {
this.loadPlans(); this.loadPlans();
} else { } else {
@ -60,7 +64,7 @@ class PricingPage extends React.Component {
loadPlans() { loadPlans() {
const plans = this.state.pricing.plans.map((plan) => const plans = this.state.pricing.plans.map((plan) =>
PlanBackend.getPlanById(plan, true)); PlanBackend.getPlan(this.state.owner, plan, true));
Promise.all(plans) Promise.all(plans)
.then(results => { .then(results => {
@ -70,7 +74,7 @@ class PricingPage extends React.Component {
return; return;
} }
this.setState({ this.setState({
plans: results, plans: results.map(result => result.data),
loading: false, loading: false,
}); });
}) })
@ -90,7 +94,6 @@ class PricingPage extends React.Component {
Setting.showMessage("error", res.msg); Setting.showMessage("error", res.msg);
return; return;
} }
this.setState({ this.setState({
loading: false, loading: false,
pricing: res.data, pricing: res.data,
@ -105,9 +108,12 @@ class PricingPage extends React.Component {
renderCards() { renderCards() {
const getUrlByPlan = (plan) => { const getUrlByPlan = (planName) => {
const pricing = this.state.pricing; const pricing = this.state.pricing;
const signUpUrl = `/signup/${pricing.application}?plan=${plan}&pricing=${pricing.name}`; let signUpUrl = `/signup/${pricing.application}?plan=${planName}&pricing=${pricing.name}`;
if (this.state.userName) {
signUpUrl = `/buy-plan/${pricing.owner}/${pricing.name}?plan=${planName}&user=${this.state.userName}`;
}
return `${window.location.origin}${signUpUrl}`; return `${window.location.origin}${signUpUrl}`;
}; };

View File

@ -29,21 +29,16 @@ class SingleCard extends React.Component {
} }
renderCard(plan, isSingle, link) { renderCard(plan, isSingle, link) {
return ( return (
<Col style={{minWidth: "320px", paddingLeft: "20px", paddingRight: "20px", paddingBottom: "20px", marginBottom: "20px", paddingTop: "0px"}} span={6}> <Col style={{minWidth: "320px", paddingLeft: "20px", paddingRight: "20px", paddingBottom: "20px", marginBottom: "20px", paddingTop: "0px"}} span={6}>
<Card <Card
hoverable hoverable
onClick={() => Setting.isMobile() ? window.location.href = link : null} onClick={() => Setting.isMobile() ? window.location.href = link : null}
style={isSingle ? {width: "320px", height: "100%"} : {width: "100%", height: "100%", paddingTop: "0px"}} style={isSingle ? {width: "320px", height: "100%"} : {width: "100%", height: "100%", paddingTop: "0px"}}
title={<h2>{plan.displayName}</h2>}
> >
<div style={{textAlign: "right"}}>
<h2
style={{marginTop: "0px"}}>{plan.displayName}</h2>
</div>
<div style={{textAlign: "left"}} className="px-10 mt-5"> <div style={{textAlign: "left"}} className="px-10 mt-5">
<span style={{fontWeight: 700, fontSize: "48px"}}>$ {plan.pricePerMonth}</span> <span style={{fontSize: "40px", fontWeight: 700}}>{Setting.getCurrencySymbol(plan.currency)} {plan.pricePerMonth}</span>
<span style={{fontSize: "18px", fontWeight: 600, color: "gray"}}> {i18next.t("plan:per month")}</span> <span style={{fontSize: "18px", fontWeight: 600, color: "gray"}}> {i18next.t("plan:per month")}</span>
</div> </div>