feat: add subscription managment (#1858)

* feat: subscription managment

* fix: remove console log

* fix: webhooks

* fix linter

* fix: fix via gofumpt

* fix: review changes

* fix: Copyright 2023

* Update account.go

---------

Co-authored-by: hsluoyz <hsluoyz@qq.com>
This commit is contained in:
Ilya Sulimanov
2023-05-20 10:56:21 +03:00
committed by GitHub
parent 319031da28
commit 88c0856d17
39 changed files with 4773 additions and 13 deletions

View File

@ -44,6 +44,12 @@ import SyncerListPage from "./SyncerListPage";
import SyncerEditPage from "./SyncerEditPage";
import CertListPage from "./CertListPage";
import CertEditPage from "./CertEditPage";
import SubscriptionListPage from "./SubscriptionListPage";
import SubscriptionEditPage from "./SubscriptionEditPage";
import PricingListPage from "./PricingListPage";
import PricingEditPage from "./PricingEditPage";
import PlanListPage from "./PlanListPage";
import PlanEditPage from "./PlanEditPage";
import ChatListPage from "./ChatListPage";
import ChatEditPage from "./ChatEditPage";
import ChatPage from "./ChatPage";
@ -168,6 +174,12 @@ class App extends Component {
this.setState({selectedMenuKey: "/result"});
} else if (uri.includes("/sysinfo")) {
this.setState({selectedMenuKey: "/sysinfo"});
} else if (uri.includes("/subscriptions")) {
this.setState({selectedMenuKey: "/subscriptions"});
} else if (uri.includes("/plans")) {
this.setState({selectedMenuKey: "/plans"});
} else if (uri.includes("/pricings")) {
this.setState({selectedMenuKey: "/pricings"});
} else {
this.setState({selectedMenuKey: -1});
}
@ -335,6 +347,8 @@ class App extends Component {
const onClick = (e) => {
if (e.key === "/account") {
this.props.history.push("/account");
} else if (e.key === "/subscription") {
this.props.history.push("/subscription");
} else if (e.key === "/chat") {
this.props.history.push("/chat");
} else if (e.key === "/logout") {
@ -444,6 +458,19 @@ class App extends Component {
res.push(Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>,
"/records"
));
res.push(Setting.getItem(<Link to="/plans">{i18next.t("general:Plans")}</Link>,
"/plans"
));
res.push(Setting.getItem(<Link to="/pricings">{i18next.t("general:Pricings")}</Link>,
"/pricings"
));
res.push(Setting.getItem(<Link to="/subscriptions">{i18next.t("general:Subscriptions")}</Link>,
"/subscriptions"
));
}
if (Setting.isLocalAdminUser(this.state.account)) {
@ -468,6 +495,7 @@ class App extends Component {
));
if (Conf.EnableExtraPages) {
res.push(Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>,
"/products"
));
@ -556,6 +584,12 @@ class App extends Component {
<Route exact path="/chat" render={(props) => this.renderLoginIfNotLoggedIn(<ChatPage account={this.state.account} {...props} />)} />
<Route exact path="/messages" render={(props) => this.renderLoginIfNotLoggedIn(<MessageListPage account={this.state.account} {...props} />)} />
<Route exact path="/messages/:messageName" render={(props) => this.renderLoginIfNotLoggedIn(<MessageEditPage account={this.state.account} {...props} />)} />
<Route exact path="/plans" render={(props) => this.renderLoginIfNotLoggedIn(<PlanListPage account={this.state.account} {...props} />)} />
<Route exact path="/plan/:organizationName/:planName" render={(props) => this.renderLoginIfNotLoggedIn(<PlanEditPage account={this.state.account} {...props} />)} />
<Route exact path="/pricings" render={(props) => this.renderLoginIfNotLoggedIn(<PricingListPage account={this.state.account} {...props} />)} />
<Route exact path="/pricing/:organizationName/:pricingName" render={(props) => this.renderLoginIfNotLoggedIn(<PricingEditPage account={this.state.account} {...props} />)} />
<Route exact path="/subscriptions" render={(props) => this.renderLoginIfNotLoggedIn(<SubscriptionListPage account={this.state.account} {...props} />)} />
<Route exact path="/subscription/:organizationName/:subscriptionName" render={(props) => this.renderLoginIfNotLoggedIn(<SubscriptionEditPage account={this.state.account} {...props} />)} />
<Route exact path="/products" render={(props) => this.renderLoginIfNotLoggedIn(<ProductListPage account={this.state.account} {...props} />)} />
<Route exact path="/products/:productName" render={(props) => this.renderLoginIfNotLoggedIn(<ProductEditPage account={this.state.account} {...props} />)} />
<Route exact path="/products/:productName/buy" render={(props) => this.renderLoginIfNotLoggedIn(<ProductBuyPage account={this.state.account} {...props} />)} />
@ -674,7 +708,8 @@ class App extends Component {
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/auto-signup");
window.location.pathname.startsWith("/auto-signup") ||
window.location.pathname.startsWith("/select-plan");
}
renderPage() {

View File

@ -16,6 +16,8 @@ import React from "react";
import {Redirect, Route, Switch} from "react-router-dom";
import {Spin} from "antd";
import i18next from "i18next";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import PricingPage from "./pricing/PricingPage";
import * as Setting from "./Setting";
import * as Conf from "./Conf";
import SignupPage from "./auth/SignupPage";
@ -33,6 +35,7 @@ class EntryPage extends React.Component {
super(props);
this.state = {
application: undefined,
pricing: undefined,
};
}
@ -65,9 +68,23 @@ class EntryPage extends React.Component {
this.props.updataThemeData(themeData);
};
const onUpdatePricing = (pricing) => {
this.setState({
pricing: pricing,
});
ApplicationBackend.getApplication("admin", pricing.application)
.then((application) => {
const themeData = application !== null ? Setting.getThemeData(application.organizationObj, application) : Conf.ThemeDefault;
this.props.updataThemeData(themeData);
});
};
return (
<div className="loginBackground" style={{backgroundImage: Setting.inIframe() || Setting.isMobile() ? null : `url(${this.state.application?.formBackgroundUrl})`}}>
<Spin size="large" spinning={this.state.application === undefined} tip={i18next.t("login:Loading")} style={{margin: "0 auto"}} />
<div className="loginBackground"
style={{backgroundImage: Setting.inIframe() || Setting.isMobile() ? null : `url(${this.state.application?.formBackgroundUrl})`}}>
<Spin size="large" spinning={this.state.application === undefined && this.state.pricing === undefined} tip={i18next.t("login:Loading")}
style={{margin: "0 auto"}} />
<Switch>
<Route exact path="/signup" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} applicationName={authConfig.appName} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/signup/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
@ -85,6 +102,7 @@ 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="/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="/select-plan/:pricingName" render={(props) => this.renderHomeIfLoggedIn(<PricingPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />)} />
</Switch>
</div>
);

273
web/src/PlanEditPage.js Normal file
View File

@ -0,0 +1,273 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as RoleBackend from "./backend/RoleBackend";
import * as PlanBackend from "./backend/PlanBackend";
import * as UserBackend from "./backend/UserBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
const {Option} = Select;
class PlanEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
planName: props.match.params.planName,
plan: null,
organizations: [],
users: [],
roles: [],
providers: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getPlan();
this.getOrganizations();
}
getPlan() {
PlanBackend.getPlan(this.state.organizationName, this.state.planName)
.then((plan) => {
this.setState({
plan: plan,
});
this.getUsers(plan.owner);
this.getRoles(plan.owner);
});
}
getRoles(organizationName) {
RoleBackend.getRoles(organizationName)
.then((res) => {
this.setState({
roles: res,
});
});
}
getUsers(organizationName) {
UserBackend.getUsers(organizationName)
.then((res) => {
this.setState({
users: res,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parsePlanField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updatePlanField(key, value) {
value = this.parsePlanField(key, value);
const plan = this.state.plan;
plan[key] = value;
this.setState({
plan: plan,
});
}
renderPlan() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("plan:New Plan") : i18next.t("plan:Edit Plan")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitPlanEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitPlanEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deletePlan()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.owner} onChange={(owner => {
this.updatePlanField("owner", owner);
this.getUsers(owner);
this.getRoles(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.plan.name} onChange={e => {
this.updatePlanField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.plan.displayName} onChange={e => {
this.updatePlanField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("role:Sub roles"), i18next.t("plan:Sub roles - Tooltip"))} :
</Col>
<Col span={22} >
<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}`))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.plan.description} onChange={e => {
this.updatePlanField("description", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("plan:PricePerMonth"), i18next.t("plan:PricePerMonth - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.plan.pricePerMonth} onChange={value => {
this.updatePlanField("pricePerMonth", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("plan:PricePerYear"), i18next.t("plan:PricePerYear - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.plan.pricePerYear} onChange={value => {
this.updatePlanField("pricePerYear", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("payment:Currency"), i18next.t("payment:Currency - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.currency} onChange={(value => {
this.updatePlanField("currency", value);
})}>
{
[
{id: "USD", name: "USD"},
{id: "CNY", name: "CNY"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</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.plan.isEnabled} onChange={checked => {
this.updatePlanField("isEnabled", checked);
}} />
</Col>
</Row>
</Card>
);
}
submitPlanEdit(willExist) {
const plan = Setting.deepCopy(this.state.plan);
PlanBackend.updatePlan(this.state.organizationName, this.state.planName, plan)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
planName: this.state.plan.name,
});
if (willExist) {
this.props.history.push("/plans");
} else {
this.props.history.push(`/plan/${this.state.plan.owner}/${this.state.plan.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updatePlanField("name", this.state.planName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePlan() {
PlanBackend.deletePlan(this.state.plan)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/plans");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.plan !== null ? this.renderPlan() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitPlanEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitPlanEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deletePlan()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
}
export default PlanEditPage;

236
web/src/PlanListPage.js Normal file
View File

@ -0,0 +1,236 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Switch, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PlanBackend from "./backend/PlanBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class PlanListPage extends BaseListPage {
newPlan() {
const randomName = Setting.getRandomName();
const owner = (this.state.organizationName !== undefined) ? this.state.organizationName : this.props.account.owner;
return {
owner: owner,
name: `plan_${randomName}`,
createdTime: moment().format(),
pricePerMonth: 10,
pricePerYear: 100,
currency: "USD",
displayName: `New Plan - ${randomName}`,
};
}
addPlan() {
const newPlan = this.newPlan();
PlanBackend.addPlan(newPlan)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/plan/${newPlan.owner}/${newPlan.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePlan(i) {
PlanBackend.deletePlan(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.setState({
data: Setting.deleteRow(this.state.data, i),
pagination: {total: this.state.pagination.total - 1},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(plans) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/plans/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("plan:Price per month"),
dataIndex: "pricePerMonth",
key: "pricePerMonth",
width: "130px",
...this.getColumnSearchProps("pricePerMonth"),
},
{
title: i18next.t("plan:Price per year"),
dataIndex: "pricePerYear",
key: "pricePerYear",
width: "130px",
...this.getColumnSearchProps("pricePerYear"),
},
{
title: i18next.t("plan:Sub role"),
dataIndex: "role",
key: "role",
width: "140px",
...this.getColumnSearchProps("role"),
},
{
title: i18next.t("general:Is enabled"),
dataIndex: "isEnabled",
key: "isEnabled",
width: "120px",
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "200px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/plan/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deletePlan(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={plans} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Plans")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addPlan.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
PlanBackend.getPlans("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
if (res.status === "ok") {
this.setState({
loading: false,
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
loading: false,
isAuthorized: false,
});
}
}
});
};
}
export default PlanListPage;

309
web/src/PricingEditPage.js Normal file
View File

@ -0,0 +1,309 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {CopyOutlined} from "@ant-design/icons";
import copy from "copy-to-clipboard";
import React from "react";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as PricingBackend from "./backend/PricingBackend";
import * as PlanBackend from "./backend/PlanBackend";
import PricingPage from "./pricing/PricingPage";
import * as Setting from "./Setting";
import i18next from "i18next";
class PricingEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
pricingName: props.match.params.pricingName,
organizations: [],
application: null,
applications: [],
pricing: null,
plans: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getPricing();
this.getOrganizations();
this.getApplicationsByOrganization(this.state.organizationName);
this.getUserApplication();
}
getPricing() {
PricingBackend.getPricing(this.state.organizationName, this.state.pricingName)
.then((pricing) => {
this.setState({
pricing: pricing,
});
this.getPlans(pricing.owner);
});
}
getPlans(organizationName) {
PlanBackend.getPlans(organizationName)
.then((res) => {
this.setState({
plans: res,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parsePricingField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updatePricingField(key, value) {
value = this.parsePricingField(key, value);
const pricing = this.state.pricing;
pricing[key] = value;
this.setState({
pricing: pricing,
});
}
getApplicationsByOrganization(organizationName) {
ApplicationBackend.getApplicationsByOrganization("admin", organizationName)
.then((res) => {
this.setState({
applications: (res.msg === undefined) ? res : [],
});
});
}
getUserApplication() {
ApplicationBackend.getUserApplication(this.state.organizationName, this.state.userName)
.then((application) => {
this.setState({
application: application,
});
});
}
renderPricing() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("pricing:New Pricing") : i18next.t("pricing:Edit Pricing")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitPricingEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitPricingEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deletePricing()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.pricing.owner} onChange={(owner => {
this.updatePricingField("owner", owner);
this.getApplicationsByOrganization(owner);
this.getPlans(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.pricing.name} onChange={e => {
this.updatePricingField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.pricing.displayName} onChange={e => {
this.updatePricingField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.pricing.description} onChange={e => {
this.updatePricingField("description", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.pricing.application}
onChange={(value => {this.updatePricingField("application", value);})}
options={this.state.applications.map((application) => Setting.getOption(application.name, application.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("pricing:Sub plans"), i18next.t("Pricing:Sub plans - Tooltip"))} :
</Col>
<Col span={22} >
<Select mode="tags" style={{width: "100%"}} value={this.state.pricing.plans}
onChange={(value => {
this.updatePricingField("plans", value);
})}
options={this.state.plans.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("pricing:Has trial"), i18next.t("pricing:Has trial - Tooltip"))} :
</Col>
<Col span={1} >
<Switch disabled={true} checked={this.state.pricing.hasTrial} onChange={checked => {
this.updatePricingField("hasTrial", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("pricing:Trial duration"), i18next.t("pricing:Trial duration - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber min={1} value={this.state.pricing.trialDuration} onChange={value => {
this.updatePricingField("trialDuration", value);
}} />
</Col>
</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.pricing.isEnabled} onChange={checked => {
this.updatePricingField("isEnabled", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Preview"), i18next.t("general:Preview - Tooltip"))} :
</Col>
{
this.renderPreview()
}
</Row>
</Card>
);
}
submitPricingEdit(willExist) {
const pricing = Setting.deepCopy(this.state.pricing);
PricingBackend.updatePricing(this.state.organizationName, this.state.pricingName, pricing)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
pricingName: this.state.pricing.name,
});
if (willExist) {
this.props.history.push("/pricings");
} else {
this.props.history.push(`/pricing/${this.state.pricing.owner}/${this.state.pricing.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updatePricingField("name", this.state.pricingName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePricing() {
PricingBackend.deletePricing(this.state.pricing)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/pricings");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.pricing !== null ? this.renderPricing() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitPricingEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitPricingEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deletePricing()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
renderPreview() {
const pricingUrl = `/select-plan/${this.state.pricing.name}`;
return (
<React.Fragment>
<Col>
<Button style={{marginBottom: "10px", marginTop: Setting.isMobile() ? "15px" : "0"}} type="primary" shape="round" icon={<CopyOutlined />} onClick={() => {
copy(`${window.location.origin}${pricingUrl}`);
Setting.showMessage("success", i18next.t("pricing:pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser"));
}}
>
{i18next.t("pricing:Copy pricing page URL")}
</Button>
</Col>
<Col>
<PricingPage pricing={this.state.pricing}></PricingPage>
</Col>
</React.Fragment>
);
}
}
export default PricingEditPage;

217
web/src/PricingListPage.js Normal file
View File

@ -0,0 +1,217 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Switch, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PricingBackend from "./backend/PricingBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class PricingListPage extends BaseListPage {
newPricing() {
const randomName = Setting.getRandomName();
const owner = (this.state.organizationName !== undefined) ? this.state.organizationName : this.props.account.owner;
return {
owner: owner,
name: `pricing_${randomName}`,
createdTime: moment().format(),
plans: [],
displayName: `New Pricing - ${randomName}`,
hasTrial: true,
isEnabled: true,
trialDuration: 14,
};
}
addPricing() {
const newPricing = this.newPricing();
PricingBackend.addPricing(newPricing)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/pricing/${newPricing.owner}/${newPricing.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePricing(i) {
PricingBackend.deletePricing(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.setState({
data: Setting.deleteRow(this.state.data, i),
pagination: {total: this.state.pagination.total - 1},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(pricings) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/pricing/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("general:Is enabled"),
dataIndex: "isEnabled",
key: "isEnabled",
width: "120px",
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "230px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/pricing/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deletePricing(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={pricings} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Pricings")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addPricing.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
PricingBackend.getPricings("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
if (res.status === "ok") {
this.setState({
loading: false,
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
loading: false,
isAuthorized: false,
});
}
}
});
};
}
export default PricingListPage;

View File

@ -0,0 +1,333 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import moment from "moment";
import React from "react";
import {Button, Card, Col, DatePicker, Input, InputNumber, Row, Select, Switch} from "antd";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as PlanBackend from "./backend/PlanBackend";
import * as SubscriptionBackend from "./backend/SubscriptionBackend";
import * as UserBackend from "./backend/UserBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
import dayjs from "dayjs";
class SubscriptionEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
subscriptionName: props.match.params.subscriptionName,
subscription: null,
organizations: [],
users: [],
planes: [],
providers: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getSubscription();
this.getOrganizations();
}
getSubscription() {
SubscriptionBackend.getSubscription(this.state.organizationName, this.state.subscriptionName)
.then((subscription) => {
this.setState({
subscription: subscription,
});
this.getUsers(subscription.owner);
this.getPlanes(subscription.owner);
});
}
getPlanes(organizationName) {
PlanBackend.getPlans(organizationName)
.then((res) => {
this.setState({
planes: res,
});
});
}
getUsers(organizationName) {
UserBackend.getUsers(organizationName)
.then((res) => {
this.setState({
users: res,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parseSubscriptionField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updateSubscriptionField(key, value) {
value = this.parseSubscriptionField(key, value);
const subscription = this.state.subscription;
subscription[key] = value;
this.setState({
subscription: subscription,
});
}
renderSubscription() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("subscription:New Subscription") : i18next.t("subscription:Edit Subscription")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitSubscriptionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitSubscriptionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteSubscription()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.owner} onChange={(owner => {
this.updateSubscriptionField("owner", owner);
this.getUsers(owner);
this.getPlanes(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.name} onChange={e => {
this.updateSubscriptionField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.displayName} onChange={e => {
this.updateSubscriptionField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Duration"), i18next.t("subscription:Duration - Tooltip"))}
</Col>
<Col span={22} >
<InputNumber value={this.state.subscription.duration} onChange={value => {
this.updateSubscriptionField("duration", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Start Date"), i18next.t("subscription:Start Date - Tooltip"))}
</Col>
<Col span={22} >
<DatePicker value={dayjs(this.state.subscription.startDate)} onChange={value => {
this.updateSubscriptionField("startDate", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:End Date"), i18next.t("subscription:End Date - Tooltip"))}
</Col>
<Col span={22} >
<DatePicker value={dayjs(this.state.subscription.endDate)} onChange={value => {
this.updateSubscriptionField("endDate", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Sub users"), i18next.t("subscription:Sub users - Tooltip"))} :
</Col>
<Col span={22} >
<Select style={{width: "100%"}} value={this.state.subscription.user}
onChange={(value => {this.updateSubscriptionField("user", value);})}
options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Sub plan"), i18next.t("subscription:Sub plan - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.plan} onChange={(value => {this.updateSubscriptionField("plan", value);})}
options={this.state.planes.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.description} onChange={e => {
this.updateSubscriptionField("description", e.target.value);
}} />
</Col>
</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("general:Submitter"), i18next.t("general: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("general:Approver"), i18next.t("general: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("general:Approve time"), i18next.t("general: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"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} disabled={!Setting.isLocalAdminUser(this.props.account)} style={{width: "100%"}} value={this.state.subscription.state} onChange={(value => {
if (this.state.subscription.state !== value) {
if (value === "Approved") {
this.updateSubscriptionField("approver", this.props.account.name);
this.updateSubscriptionField("approveTime", moment().format());
} else {
this.updateSubscriptionField("approver", "");
this.updateSubscriptionField("approveTime", "");
}
}
this.updateSubscriptionField("state", value);
})}
options={[
{value: "Approved", name: i18next.t("subscription:Approved")},
{value: "Pending", name: i18next.t("subscription:Pending")},
].map((item) => Setting.getOption(item.name, item.value))}
/>
</Col>
</Row>
</Card>
);
}
submitSubscriptionEdit(willExist) {
const subscription = Setting.deepCopy(this.state.subscription);
SubscriptionBackend.updateSubscription(this.state.organizationName, this.state.subscriptionName, subscription)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
subscriptionName: this.state.subscription.name,
});
if (willExist) {
this.props.history.push("/subscriptions");
} else {
this.props.history.push(`/subscription/${this.state.subscription.owner}/${this.state.subscription.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updateSubscriptionField("name", this.state.subscriptionName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteSubscription() {
SubscriptionBackend.deleteSubscription(this.state.subscription)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/subscriptions");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.subscription !== null ? this.renderSubscription() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitSubscriptionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitSubscriptionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deleteSubscription()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
}
export default SubscriptionEditPage;

View File

@ -0,0 +1,239 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as SubscriptionBackend from "./backend/SubscriptionBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class SubscriptionListPage extends BaseListPage {
newSubscription() {
const randomName = Setting.getRandomName();
const owner = (this.state.organizationName !== undefined) ? this.state.organizationName : this.props.account.owner;
const defaultDuration = 365;
return {
owner: owner,
name: `subscription_${randomName}`,
createdTime: moment().format(),
startDate: moment().format(),
endDate: moment().add(defaultDuration, "d").format(),
displayName: `New Subscription - ${randomName}`,
tag: "",
users: [],
expireInDays: defaultDuration,
submitter: this.props.account.name,
approver: "",
approveTime: "",
state: "Pending",
};
}
addSubscription() {
const newSubscription = this.newSubscription();
SubscriptionBackend.addSubscription(newSubscription)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/subscription/${newSubscription.owner}/${newSubscription.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteSubscription(i) {
SubscriptionBackend.deleteSubscription(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.setState({
data: Setting.deleteRow(this.state.data, i),
pagination: {total: this.state.pagination.total - 1},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(subscriptions) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/subscriptions/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("subscription:Duration"),
dataIndex: "duration",
key: "duration",
width: "140px",
...this.getColumnSearchProps("duration"),
},
{
title: i18next.t("subscription:Sub plane"),
dataIndex: "plan",
key: "plan",
width: "140px",
...this.getColumnSearchProps("plan"),
},
{
title: i18next.t("subscription:Sub user"),
dataIndex: "user",
key: "user",
width: "140px",
...this.getColumnSearchProps("user"),
},
{
title: i18next.t("general:State"),
dataIndex: "state",
key: "state",
width: "120px",
sorter: true,
...this.getColumnSearchProps("state"),
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "230px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/subscription/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteSubscription(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={subscriptions} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Subscriptions")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addSubscription.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
SubscriptionBackend.getSubscriptions("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
if (res.status === "ok") {
this.setState({
loading: false,
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
loading: false,
isAuthorized: false,
});
}
}
});
};
}
export default SubscriptionListPage;

View File

@ -259,7 +259,7 @@ class WebhookEditPage extends React.Component {
}} >
{
(
["signup", "login", "logout", "add-user", "update-user", "delete-user", "add-organization", "update-organization", "delete-organization", "add-application", "update-application", "delete-application", "add-provider", "update-provider", "delete-provider"].map((option, index) => {
["signup", "login", "logout", "add-user", "update-user", "delete-user", "add-organization", "update-organization", "delete-organization", "add-application", "update-application", "delete-application", "add-provider", "update-provider", "delete-provider", "update-subscription"].map((option, index) => {
return (
<Option key={option} value={option}>{option}</Option>
);

View File

@ -438,6 +438,7 @@ class LoginPage extends React.Component {
<Form
name="normal_login"
initialValues={{
organization: application.organization,
application: application.name,
autoSignin: true,

View File

@ -165,6 +165,11 @@ class SignupPage extends React.Component {
onFinish(values) {
const application = this.getApplicationObj();
const params = new URLSearchParams(window.location.search);
values["plan"] = params.get("plan");
values["pricing"] = params.get("pricing");
AuthBackend.signup(values)
.then((res) => {
if (res.status === "ok") {

View File

@ -0,0 +1,81 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getPlans(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-plans?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPlanById(id, includeOption = false) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${id}&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",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updatePlan(owner, name, plan) {
const newPlan = Setting.deepCopy(plan);
return fetch(`${Setting.ServerUrl}/api/update-plan?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPlan),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addPlan(plan) {
const newPlan = Setting.deepCopy(plan);
return fetch(`${Setting.ServerUrl}/api/add-plan`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPlan),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deletePlan(plan) {
const newPlan = Setting.deepCopy(plan);
return fetch(`${Setting.ServerUrl}/api/delete-plan`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPlan),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@ -0,0 +1,71 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getPricings(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-pricings?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPricing(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-pricing?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updatePricing(owner, name, pricing) {
const newPricing = Setting.deepCopy(pricing);
return fetch(`${Setting.ServerUrl}/api/update-pricing?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPricing),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addPricing(pricing) {
const newPricing = Setting.deepCopy(pricing);
return fetch(`${Setting.ServerUrl}/api/add-pricing`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPricing),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deletePricing(pricing) {
const newPricing = Setting.deepCopy(pricing);
return fetch(`${Setting.ServerUrl}/api/delete-pricing`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPricing),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@ -0,0 +1,71 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getSubscriptions(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-subscriptions?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getSubscription(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-subscription?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updateSubscription(owner, name, subscription) {
const newSubscription = Setting.deepCopy(subscription);
return fetch(`${Setting.ServerUrl}/api/update-subscription?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSubscription),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addSubscription(subscription) {
const newSubscription = Setting.deepCopy(subscription);
return fetch(`${Setting.ServerUrl}/api/add-subscription`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSubscription),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deleteSubscription(subscription) {
const newSubscription = Setting.deepCopy(subscription);
return fetch(`${Setting.ServerUrl}/api/delete-subscription`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSubscription),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@ -253,6 +253,8 @@
"Phone": "Telefon",
"Phone - Tooltip": "Telefonnummer",
"Phone or email": "Phone or email",
"Plans": "Pläne",
"Pricings": "Preise",
"Preview": "Vorschau",
"Preview - Tooltip": "Vorschau der konfigurierten Effekte",
"Products": "Produkte",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Es tut uns leid, aber Sie haben keine Berechtigung, auf diese Seite zuzugreifen, oder Sie sind nicht angemeldet.",
"State": "Bundesland / Staat",
"State - Tooltip": "Bundesland",
"Subscriptions": "Abonnements",
"Successfully added": "Erfolgreich hinzugefügt",
"Successfully deleted": "Erfolgreich gelöscht",
"Successfully saved": "Erfolgreich gespeichert",
@ -884,5 +887,36 @@
"Method - Tooltip": "HTTP Methode",
"New Webhook": "Neue Webhook",
"Value": "Wert"
},
"plan": {
"Sub roles - Tooltip": "Rolle im aktuellen Plan enthalten",
"PricePerMonth": "Preis pro Monat",
"PricePerYear": "Preis pro Jahr",
"PerMonth": "pro Monat"
},
"pricing": {
"Sub plans": "Zusatzpläne",
"Sub plans - Tooltip": "Pläne im aktuellen Preismodell enthalten",
"Has trial": "Testphase verfügbar",
"Has trial - Tooltip": "Verfügbarkeit der Testphase nach Auswahl eines Plans",
"Trial duration": "Testphase Dauer",
"Trial duration - Tooltip": "Dauer der Testphase",
"Getting started": "Loslegen",
"Copy pricing page URL": "Preisseite URL kopieren",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "Preisseite URL erfolgreich in die Zwischenablage kopiert. Bitte fügen Sie sie in ein Inkognito-Fenster oder einen anderen Browser ein.",
"days trial available!": "Tage Testphase verfügbar!",
"Free": "Kostenlos"
},
"subscription": {
"Duration": "Laufzeit",
"Duration - Tooltip": "Laufzeit des Abonnements",
"Start Date": "Startdatum",
"Start Date - Tooltip": "Startdatum",
"End Date": "Enddatum",
"End Date - Tooltip": "Enddatum",
"Sub users": "Abonnenten",
"Sub users - Tooltip": "Abonnenten",
"Sub plan": "Abonnementplan",
"Sub plan - Tooltip": "Abonnementplan"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "Phone",
"Phone - Tooltip": "Phone number",
"Phone or email": "Phone or email",
"Plans": "Plans",
"Pricings": "Pricings",
"Preview": "Preview",
"Preview - Tooltip": "Preview the configured effects",
"Products": "Products",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Sorry, you do not have permission to access this page or logged in status invalid.",
"State": "State",
"State - Tooltip": "State",
"Subscriptions": "Subscriptions",
"Successfully added": "Successfully added",
"Successfully deleted": "Successfully deleted",
"Successfully saved": "Successfully saved",
@ -884,5 +887,37 @@
"Method - Tooltip": "HTTP method",
"New Webhook": "New Webhook",
"Value": "Value"
},
"plan": {
"Sub roles - Tooltip": "Role included in the current plane",
"PricePerMonth": "Price per month",
"PricePerYear": "Price per year",
"PerMonth": "per month"
},
"pricing": {
"Sub plans": "Sub plans",
"Sub plans - Tooltip": "Plans included in the current pricing",
"Has trial": "Has trial",
"Has trial - Tooltip": "Availability of the trial period after choosing a plan",
"Trial duration": "Trial duration",
"Trial duration - Tooltip": "Trial duration period",
"Getting started" : "Getting started",
"Copy pricing page URL": "Copy pricing page URL",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser" : "pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser",
"days trial available!": "days trial available!",
"Free": "Free"
},
"subscription": {
"Duration": "Duration",
"Duration - Tooltip": "Subscription duration",
"Start Date": "Start Date",
"Start Date - Tooltip": "Start Date",
"End Date": "End Date",
"End Date - Tooltip": "End Date",
"Sub users": "Sub users",
"Sub users - Tooltip": "Sub users",
"Sub plan": "Sub plan",
"Sub plan - Tooltip": "Sub plan"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "Teléfono",
"Phone - Tooltip": "Número de teléfono",
"Phone or email": "Phone or email",
"Plans": "Planes",
"Pricings": "Precios",
"Preview": "Avance",
"Preview - Tooltip": "Vista previa de los efectos configurados",
"Products": "Productos",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Lo siento, no tiene permiso para acceder a esta página o su estado de inicio de sesión es inválido.",
"State": "Estado",
"State - Tooltip": "Estado",
"Subscriptions": "Suscripciones",
"Successfully added": "Éxito al agregar",
"Successfully deleted": "Éxito en la eliminación",
"Successfully saved": "Guardado exitosamente",
@ -884,5 +887,36 @@
"Method - Tooltip": "Método HTTP",
"New Webhook": "Nuevo Webhook",
"Value": "Valor"
},
"plan": {
"Sub roles - Tooltip": "Rol incluido en el plan actual",
"PricePerMonth": "Precio por mes",
"PricePerYear": "Precio por año",
"PerMonth": "por mes"
},
"pricing": {
"Sub plans": "Planes adicionales",
"Sub plans - Tooltip": "Planes incluidos en la tarifa actual",
"Has trial": "Tiene período de prueba",
"Has trial - Tooltip": "Disponibilidad del período de prueba después de elegir un plan",
"Trial duration": "Duración del período de prueba",
"Trial duration - Tooltip": "Duración del período de prueba",
"Getting started": "Empezar",
"Copy pricing page URL": "Copiar URL de la página de precios",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL de la página de precios copiada correctamente al portapapeles, péguela en una ventana de incógnito u otro navegador",
"days trial available!": "días de prueba disponibles",
"Free": "Gratis"
},
"subscription": {
"Duration": "Duración",
"Duration - Tooltip": "Duración de la suscripción",
"Start Date": "Fecha de inicio",
"Start Date - Tooltip": "Fecha de inicio",
"End Date": "Fecha de finalización",
"End Date - Tooltip": "Fecha de finalización",
"Sub users": "Usuarios de la suscripción",
"Sub users - Tooltip": "Usuarios de la suscripción",
"Sub plan": "Plan de suscripción",
"Sub plan - Tooltip": "Plan de suscripción"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "Téléphone",
"Phone - Tooltip": "Numéro de téléphone",
"Phone or email": "Phone or email",
"Plans": "Plans",
"Pricings": "Tarifs",
"Preview": "Aperçu",
"Preview - Tooltip": "Prévisualisez les effets configurés",
"Products": "Produits",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Désolé, vous n'avez pas la permission d'accéder à cette page ou votre statut de connexion est invalide.",
"State": "État",
"State - Tooltip": "État",
"Subscriptions": "Abonnements",
"Successfully added": "Ajouté avec succès",
"Successfully deleted": "Supprimé avec succès",
"Successfully saved": "Succès enregistré",
@ -884,5 +887,36 @@
"Method - Tooltip": "Méthode HTTP",
"New Webhook": "Nouveau webhook",
"Value": "Valeur"
},
"plan": {
"Sub roles - Tooltip": "Rôle inclus dans le plan actuel",
"PricePerMonth": "Prix par mois",
"PricePerYear": "Prix par an",
"PerMonth": "par mois"
},
"pricing": {
"Sub plans": "Forfaits supplémentaires",
"Sub plans - Tooltip": "Forfaits inclus dans la tarification actuelle",
"Has trial": "Essai gratuit disponible",
"Has trial - Tooltip": "Disponibilité de la période d'essai après avoir choisi un forfait",
"Trial duration": "Durée de l'essai",
"Trial duration - Tooltip": "Durée de la période d'essai",
"Getting started": "Commencer",
"Copy pricing page URL": "Copier l'URL de la page tarifs",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL de la page tarifs copiée avec succès dans le presse-papiers, veuillez le coller dans une fenêtre de navigation privée ou un autre navigateur",
"days trial available!": "jours d'essai disponibles !",
"Free": "Gratuit"
},
"subscription": {
"Duration": "Durée",
"Duration - Tooltip": "Durée de l'abonnement",
"Start Date": "Date de début",
"Start Date - Tooltip": "Date de début",
"End Date": "Date de fin",
"End Date - Tooltip": "Date de fin",
"Sub users": "Utilisateurs de l'abonnement",
"Sub users - Tooltip": "Utilisateurs de l'abonnement",
"Sub plan": "Plan de l'abonnement",
"Sub plan - Tooltip": "Plan de l'abonnement"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "Telepon",
"Phone - Tooltip": "Nomor telepon",
"Phone or email": "Phone or email",
"Plans": "Rencana",
"Pricings": "Harga",
"Preview": "Tinjauan",
"Preview - Tooltip": "Mengawali pratinjau efek yang sudah dikonfigurasi",
"Products": "Produk",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Maaf, Anda tidak memiliki izin untuk mengakses halaman ini atau status masuk tidak valid.",
"State": "Negara",
"State - Tooltip": "Negara",
"Subscriptions": "Langganan",
"Successfully added": "Berhasil ditambahkan",
"Successfully deleted": "Berhasil dihapus",
"Successfully saved": "Berhasil disimpan",
@ -884,5 +887,36 @@
"Method - Tooltip": "Metode HTTP",
"New Webhook": "Webhook Baru",
"Value": "Nilai"
},
"plan": {
"Sub roles - Tooltip": "Peran yang termasuk dalam rencana saat ini",
"PricePerMonth": "Harga per bulan",
"PricePerYear": "Harga per tahun",
"PerMonth": "per bulan"
},
"pricing": {
"Sub plans": "Rencana Tambahan",
"Sub plans - Tooltip": "Rencana yang termasuk dalam harga saat ini",
"Has trial": "Mempunyai periode percobaan",
"Has trial - Tooltip": "Ketersediaan periode percobaan setelah memilih rencana",
"Trial duration": "Durasi percobaan",
"Trial duration - Tooltip": "Durasi periode percobaan",
"Getting started": "Mulai",
"Copy pricing page URL": "Salin URL halaman harga",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL halaman harga berhasil disalin ke clipboard, silakan tempelkan ke dalam jendela mode penyamaran atau browser lainnya",
"days trial available!": "hari percobaan tersedia!",
"Free": "Gratis"
},
"subscription": {
"Duration": "Durasi",
"Duration - Tooltip": "Durasi langganan",
"Start Date": "Tanggal Mulai",
"Start Date - Tooltip": "Tanggal Mulai",
"End Date": "Tanggal Berakhir",
"End Date - Tooltip": "Tanggal Berakhir",
"Sub users": "Pengguna Langganan",
"Sub users - Tooltip": "Pengguna Langganan",
"Sub plan": "Rencana Langganan",
"Sub plan - Tooltip": "Rencana Langganan"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "電話",
"Phone - Tooltip": "電話番号",
"Phone or email": "Phone or email",
"Plans": "プラン",
"Pricings": "価格設定",
"Preview": "プレビュー",
"Preview - Tooltip": "構成されたエフェクトをプレビューする",
"Products": "製品",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "申し訳ありませんが、このページにアクセスする権限がありません、またはログイン状態が無効です。",
"State": "州",
"State - Tooltip": "状態",
"Subscriptions": "サブスクリプション",
"Successfully added": "正常に追加されました",
"Successfully deleted": "正常に削除されました",
"Successfully saved": "成功的に保存されました",
@ -884,5 +887,36 @@
"Method - Tooltip": "HTTPメソッド",
"New Webhook": "新しいWebhook",
"Value": "値"
},
"plan": {
"Sub roles - Tooltip": "現在のプランに含まれるロール",
"PricePerMonth": "月額料金",
"PricePerYear": "年間料金",
"PerMonth": "月毎"
},
"pricing": {
"Sub plans": "追加プラン",
"Sub plans - Tooltip": "現在の価格設定に含まれるプラン",
"Has trial": "トライアル期間あり",
"Has trial - Tooltip": "プラン選択後のトライアル期間の有無",
"Trial duration": "トライアル期間の長さ",
"Trial duration - Tooltip": "トライアル期間の長さ",
"Getting started": "はじめる",
"Copy pricing page URL": "価格ページのURLをコピー",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "価格ページのURLが正常にクリップボードにコピーされました。シークレットウィンドウや別のブラウザに貼り付けてください。",
"days trial available!": "日間のトライアルが利用可能です!",
"Free": "無料"
},
"subscription": {
"Duration": "期間",
"Duration - Tooltip": "購読の期間",
"Start Date": "開始日",
"Start Date - Tooltip": "開始日",
"End Date": "終了日",
"End Date - Tooltip": "終了日",
"Sub users": "購読ユーザー",
"Sub users - Tooltip": "購読ユーザー",
"Sub plan": "購読プラン",
"Sub plan - Tooltip": "購読プラン"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "전화기",
"Phone - Tooltip": "전화 번호",
"Phone or email": "Phone or email",
"Plans": "플랜",
"Pricings": "가격",
"Preview": "미리보기",
"Preview - Tooltip": "구성된 효과를 미리보기합니다",
"Products": "제품들",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "죄송합니다. 이 페이지에 접근할 권한이 없거나 로그인 상태가 유효하지 않습니다.",
"State": "주",
"State - Tooltip": "국가",
"Subscriptions": "구독",
"Successfully added": "성공적으로 추가되었습니다",
"Successfully deleted": "성공적으로 삭제되었습니다",
"Successfully saved": "성공적으로 저장되었습니다",
@ -884,5 +887,36 @@
"Method - Tooltip": "HTTP 방법",
"New Webhook": "새로운 웹훅",
"Value": "가치"
},
"plan": {
"Sub roles - Tooltip": "현재 플랜에 포함된 역할",
"PricePerMonth": "월별 가격",
"PricePerYear": "연간 가격",
"PerMonth": "월"
},
"pricing": {
"Sub plans": "추가 플랜",
"Sub plans - Tooltip": "현재 가격 책정에 포함된 플랜",
"Has trial": "무료 체험 가능",
"Has trial - Tooltip": "플랜 선택 후 체험 기간의 가용 여부",
"Trial duration": "체험 기간",
"Trial duration - Tooltip": "체험 기간의 기간",
"Getting started": "시작하기",
"Copy pricing page URL": "가격 페이지 URL 복사",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "가격 페이지 URL이 클립보드에 성공적으로 복사되었습니다. 시크릿 창이나 다른 브라우저에 붙여넣기해주세요.",
"days trial available!": "일 무료 체험 가능!",
"Free": "무료"
},
"subscription": {
"Duration": "기간",
"Duration - Tooltip": "구독 기간",
"Start Date": "시작일",
"Start Date - Tooltip": "시작일",
"End Date": "종료일",
"End Date - Tooltip": "종료일",
"Sub users": "구독 사용자",
"Sub users - Tooltip": "구독 사용자",
"Sub plan": "구독 플랜",
"Sub plan - Tooltip": "구독 플랜"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "Телефон",
"Phone - Tooltip": "Номер телефона",
"Phone or email": "Phone or email",
"Plans": "Планы",
"Pricings": "Тарифы",
"Preview": "Предварительный просмотр",
"Preview - Tooltip": "Предварительный просмотр настроенных эффектов",
"Products": "Продукты",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "К сожалению, у вас нет разрешения на доступ к этой странице или ваш статус входа недействителен.",
"State": "Государство",
"State - Tooltip": "Государство",
"Subscriptions": "Подписки",
"Successfully added": "Успешно добавлено",
"Successfully deleted": "Успешно удалено",
"Successfully saved": "Успешно сохранено",
@ -884,5 +887,36 @@
"Method - Tooltip": "Метод HTTP",
"New Webhook": "Новый вебхук",
"Value": "Значение"
},
"plan": {
"Sub roles - Tooltip": "Роль, включенная в текущий план",
"PricePerMonth": "Цена за месяц",
"PricePerYear": "Цена за год",
"PerMonth": "в месяц"
},
"pricing": {
"Sub plans": "Тарифные планы",
"Sub plans - Tooltip": "Планы, включенные в прайслист",
"Has trial": "Есть пробный период",
"Has trial - Tooltip": "Наличие пробного периода после выбора плана",
"Trial duration": "Продолжительность пробного периода",
"Trial duration - Tooltip": "Продолжительность пробного периода",
"Getting started": "Выьрать план",
"Copy pricing page URL": "Скопировать URL прайс-листа",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL страницы прайс-листа успешно скопирован в буфер обмена, пожалуйста, вставьте его в режиме инкогнито или другом браузере",
"days trial available!": "дней пробного периода доступно!",
"Free": "Бесплатно"
},
"subscription": {
"Duration": "Продолжительность",
"Duration - Tooltip": "Продолжительность подписки",
"Start Date": "Дата начала",
"Start Date - Tooltip": "Дата начала",
"End Date": "Дата окончания",
"End Date - Tooltip": "Дата окончания",
"Sub users": "Пользователь подписки",
"Sub users - Tooltip": "Пользователь которому офомлена подписка",
"Sub plan": "План подписки",
"Sub plan - Tooltip": "План подписки"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "Điện thoại",
"Phone - Tooltip": "Số điện thoại",
"Phone or email": "Phone or email",
"Plans": "Kế hoạch",
"Pricings": "Bảng giá",
"Preview": "Xem trước",
"Preview - Tooltip": "Xem trước các hiệu ứng đã cấu hình",
"Products": "Sản phẩm",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Xin lỗi, bạn không có quyền truy cập trang này hoặc trạng thái đăng nhập không hợp lệ.",
"State": "Nhà nước",
"State - Tooltip": "Trạng thái",
"Subscriptions": "Đăng ký",
"Successfully added": "Đã thêm thành công",
"Successfully deleted": "Đã xóa thành công",
"Successfully saved": "Thành công đã được lưu lại",
@ -884,5 +887,36 @@
"Method - Tooltip": "Phương thức HTTP",
"New Webhook": "Webhook mới",
"Value": "Giá trị"
},
"plan": {
"Sub roles - Tooltip": "Vai trò bao gồm trong kế hoạch hiện tại",
"PricePerMonth": "Giá mỗi tháng",
"PricePerYear": "Giá mỗi năm",
"PerMonth": "mỗi tháng"
},
"pricing": {
"Sub plans": "Kế hoạch phụ",
"Sub plans - Tooltip": "Các kế hoạch bao gồm trong bảng giá hiện tại",
"Has trial": "Có thời gian thử nghiệm",
"Has trial - Tooltip": "Khả dụng thời gian thử nghiệm sau khi chọn kế hoạch",
"Trial duration": "Thời gian thử nghiệm",
"Trial duration - Tooltip": "Thời gian thử nghiệm",
"Getting started": "Bắt đầu",
"Copy pricing page URL": "Sao chép URL trang bảng giá",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL trang bảng giá đã được sao chép vào clipboard thành công, vui lòng dán vào cửa sổ ẩn danh hoặc trình duyệt khác",
"days trial available!": "ngày dùng thử có sẵn!",
"Free": "Miễn phí"
},
"subscription": {
"Duration": "Thời lượng",
"Duration - Tooltip": "Thời lượng đăng ký",
"Start Date": "Ngày bắt đầu",
"Start Date - Tooltip": "Ngày bắt đầu",
"End Date": "Ngày kết thúc",
"End Date - Tooltip": "Ngày kết thúc",
"Sub users": "Người dùng đăng ký",
"Sub users - Tooltip": "Người dùng đăng ký",
"Sub plan": "Kế hoạch đăng ký",
"Sub plan - Tooltip": "Kế hoạch đăng ký"
}
}

View File

@ -253,6 +253,8 @@
"Phone": "手机号",
"Phone - Tooltip": "手机号",
"Phone or email": "手机或邮箱",
"Plans": "计划",
"Pricings": "定价",
"Preview": "预览",
"Preview - Tooltip": "可预览所配置的效果",
"Products": "商品",
@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "抱歉,您无权访问该页面或登录状态失效",
"State": "状态",
"State - Tooltip": "状态",
"Subscriptions": "订阅",
"Successfully added": "添加成功",
"Successfully deleted": "删除成功",
"Successfully saved": "保存成功",
@ -884,5 +887,36 @@
"Method - Tooltip": "HTTP方法",
"New Webhook": "添加Webhook",
"Value": "值"
},
"plan": {
"Sub roles - Tooltip": "当前计划中包含的角色",
"PricePerMonth": "每月价格",
"PricePerYear": "每年价格",
"PerMonth": "每月"
},
"pricing": {
"Sub plans": "附加计划",
"Sub plans - Tooltip": "包含在当前定价中的计划",
"Has trial": "有试用期",
"Has trial - Tooltip": "选择计划后是否有试用期",
"Trial duration": "试用期时长",
"Trial duration - Tooltip": "试用期时长",
"Getting started": "开始使用",
"Copy pricing page URL": "复制定价页面链接",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "定价页面链接已成功复制到剪贴板,请粘贴到隐身窗口或其他浏览器中",
"days trial available!": "天试用期可用!",
"Free": "免费"
},
"subscription": {
"Duration": "订阅时长",
"Duration - Tooltip": "订阅时长",
"Start Date": "开始日期",
"Start Date - Tooltip": "开始日期",
"End Date": "结束日期",
"End Date - Tooltip": "结束日期",
"Sub users": "订阅用户",
"Sub users - Tooltip": "订阅用户",
"Sub plan": "订阅计划",
"Sub plan - Tooltip": "订阅计划"
}
}

View File

@ -0,0 +1,167 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Card, Col, Row} from "antd";
import * as PricingBackend from "../backend/PricingBackend";
import * as PlanBackend from "../backend/PlanBackend";
import CustomGithubCorner from "../common/CustomGithubCorner";
import * as Setting from "../Setting";
import SingleCard from "./SingleCard";
import i18next from "i18next";
class PricingPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
applications: null,
pricingName: (props.pricingName ?? props.match?.params?.pricingName) ?? null,
pricing: props.pricing,
plans: null,
loading: false,
};
}
componentDidMount() {
this.setState({
applications: [],
});
if (this.state.pricing) {
this.loadPlans();
} else {
this.loadPricing(this.state.pricingName);
}
this.setState({
loading: true,
});
}
componentDidUpdate() {
if (this.state.pricing &&
this.state.pricing.plans?.length !== this.state.plans?.length && !this.state.loading) {
this.setState({loading: true});
this.loadPlans();
}
}
loadPlans() {
const plans = this.state.pricing.plans.map((plan) =>
PlanBackend.getPlanById(plan, true));
Promise.all(plans)
.then(results => {
this.setState({
plans: results,
loading: false,
});
})
.catch(error => {
Setting.showMessage("error", `Failed to get plans: ${error}`);
});
}
loadPricing(pricingName) {
if (pricingName === undefined) {
return;
}
PricingBackend.getPricing("built-in", pricingName)
.then((result) => {
this.setState({
loading: false,
pricing: result,
});
this.onUpdatePricing(result);
});
}
onUpdatePricing(pricing) {
this.props.onUpdatePricing(pricing);
}
renderCards() {
const getUrlByPlan = (plan) => {
const pricing = this.state.pricing;
const signUpUrl = `/signup/${pricing.application}?plan=${plan}&pricing=${pricing.name}`;
return `${window.location.origin}${signUpUrl}`;
};
if (Setting.isMobile()) {
return (
<Card style={{border: "none"}} bodyStyle={{padding: 0}}>
{
this.state.plans.map(item => {
return (
<SingleCard link={getUrlByPlan(item.name)} key={item.name} plan={item} isSingle={this.state.plans.length === 1} />
);
})
}
</Card>
);
} else {
return (
<div style={{marginRight: "15px", marginLeft: "15px"}}>
<Row style={{justifyContent: "center"}} gutter={24}>
{
this.state.plans.map(item => {
return (
<SingleCard style={{marginRight: "5px", marginLeft: "5px"}} link={getUrlByPlan(item.name)} key={item.name} plan={item} isSingle={this.state.plans.length === 1} />
);
})
}
</Row>
</div>
);
}
}
render() {
if (this.state.loading || this.state.plans === null || this.state.plans === undefined) {
return null;
}
const pricing = this.state.pricing;
return (
<React.Fragment>
<CustomGithubCorner />
<div className="login-content">
<div className="login-panel">
<div className="login-form">
<h1 style={{fontSize: "48px", marginTop: "0px", marginBottom: "15px"}}>{pricing.displayName}</h1>
<span style={{fontSize: "20px"}}>{pricing.description}</span>
<Row style={{width: "100%", marginTop: "40px"}}>
<Col span={24} style={{display: "flex", justifyContent: "center"}} >
{
this.renderCards()
}
</Col>
</Row>
<Row style={{justifyContent: "center"}}>
{pricing && pricing.trialDuration > 0
? <i>{i18next.t("pricing:Free")} {pricing.trialDuration}-{i18next.t("pricing:days trial available!")}</i>
: null}
</Row>
</div>
</div>
</div>
</React.Fragment>
);
}
}
export default PricingPage;

View File

@ -0,0 +1,85 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import i18next from "i18next";
import React from "react";
import {Button, Card, Col} from "antd";
import * as Setting from "../Setting";
import {withRouter} from "react-router-dom";
const {Meta} = Card;
class SingleCard extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
renderCard(plan, isSingle, link) {
return (
<Col style={{minWidth: "320px", paddingLeft: "20px", paddingRight: "20px", paddingBottom: "20px", marginBottom: "20px", paddingTop: "0px"}} span={6}>
<Card
hoverable
onClick={() => Setting.isMobile() ? window.location.href = link : null}
style={isSingle ? {width: "320px", height: "100%"} : {width: "100%", height: "100%", paddingTop: "0px"}}
>
<div style={{textAlign: "right"}}>
<h2
style={{marginTop: "0px"}}>{plan.displayName}</h2>
</div>
<div style={{textAlign: "left"}} className="px-10 mt-5">
<span style={{fontWeight: 700, fontSize: "48px"}}>$ {plan.pricePerMonth}</span>
<span style={{fontSize: "18px", fontWeight: 600, color: "gray"}}> {i18next.t("plan:PerMonth")}</span>
</div>
<br />
<div style={{textAlign: "left", fontSize: "18px"}}>
<Meta description={plan.description} />
</div>
<br />
<ul style={{listStyleType: "none", paddingLeft: "0px", textAlign: "left"}}>
{(plan.options ?? []).map((option) => {
// eslint-disable-next-line react/jsx-key
return <li>
<svg style={{height: "1rem", width: "1rem", fill: "green", marginRight: "10px"}} xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<path d="M0 11l2-2 5 5L18 3l2 2L7 18z"></path>
</svg>
<span style={{fontSize: "16px"}}>{option}</span>
</li>;
})}
</ul>
<div style={{minHeight: "60px"}}>
</div>
<Button style={{width: "100%", position: "absolute", height: "50px", borderRadius: "0px", bottom: "0", left: "0"}} type="primary" key="subscribe" onClick={() => window.location.href = link}>
{
i18next.t("pricing:Getting started")
}
</Button>
</Card>
</Col>
);
}
render() {
return this.renderCard(this.props.plan, this.props.isSingle, this.props.link);
}
}
export default withRouter(SingleCard);