forked from casdoor/casdoor
feat: add the shopping cart page (#4855)
This commit is contained in:
@@ -30,7 +30,7 @@ type Order struct {
|
||||
|
||||
// Product Info
|
||||
Products []string `xorm:"varchar(1000)" json:"products"` // Support for multiple products per order. Using varchar(1000) for simple JSON array storage; can be refactored to separate table if needed
|
||||
ProductInfos []ProductInfo `xorm:"varchar(2000)" json:"productInfos"`
|
||||
ProductInfos []ProductInfo `xorm:"mediumtext" json:"productInfos"`
|
||||
|
||||
// Subscription Info (for subscription orders)
|
||||
PricingName string `xorm:"varchar(100)" json:"pricingName"`
|
||||
@@ -56,10 +56,12 @@ type Order struct {
|
||||
type ProductInfo struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Image string `json:"image"`
|
||||
Detail string `json:"detail"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Price float64 `json:"price"`
|
||||
IsRecharge bool `json:"isRecharge"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
IsRecharge bool `json:"isRecharge,omitempty"`
|
||||
Quantity int `json:"quantity,omitempty"`
|
||||
}
|
||||
|
||||
func GetOrderCount(owner, field, value string) (int64, error) {
|
||||
|
||||
@@ -221,6 +221,7 @@ type User struct {
|
||||
Invitation string `xorm:"varchar(100) index" json:"invitation"`
|
||||
InvitationCode string `xorm:"varchar(100) index" json:"invitationCode"`
|
||||
FaceIds []*FaceId `json:"faceIds"`
|
||||
Cart []ProductInfo `xorm:"mediumtext" json:"cart"`
|
||||
|
||||
Ldap string `xorm:"ldap varchar(100)" json:"ldap"`
|
||||
Properties map[string]string `json:"properties"`
|
||||
@@ -849,6 +850,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
|
||||
"microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify", "soundcloud",
|
||||
"spotify", "strava", "stripe", "type", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk", "wepay", "xero", "yahoo",
|
||||
"yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", "mfa_items", "mfa_remember_deadline",
|
||||
"cart",
|
||||
}
|
||||
}
|
||||
if isAdmin {
|
||||
|
||||
210
web/src/CartListPage.js
Normal file
210
web/src/CartListPage.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// Copyright 2026 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 * as Setting from "./Setting";
|
||||
import * as UserBackend from "./backend/UserBackend";
|
||||
import i18next from "i18next";
|
||||
import BaseListPage from "./BaseListPage";
|
||||
import PopconfirmModal from "./common/modal/PopconfirmModal";
|
||||
|
||||
class CartListPage extends BaseListPage {
|
||||
deleteCart(record) {
|
||||
const user = Setting.deepCopy(this.state.user);
|
||||
if (user === undefined || user === null || !Array.isArray(user.cart)) {
|
||||
Setting.showMessage("error", i18next.t("general:Failed to delete"));
|
||||
return;
|
||||
}
|
||||
|
||||
const index = user.cart.findIndex(item => item.name === record.name && item.price === record.price);
|
||||
if (index === -1) {
|
||||
Setting.showMessage("error", i18next.t("general:Failed to delete"));
|
||||
return;
|
||||
}
|
||||
|
||||
user.cart.splice(index, 1);
|
||||
|
||||
UserBackend.updateUser(user.owner, user.name, user)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
|
||||
this.fetch();
|
||||
} 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(carts) {
|
||||
const owner = this.state.user?.owner || this.props.account.owner;
|
||||
|
||||
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={`/products/${owner}/${text}`}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Display name"),
|
||||
dataIndex: "displayName",
|
||||
key: "displayName",
|
||||
width: "170px",
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: i18next.t("product:Image"),
|
||||
dataIndex: "image",
|
||||
key: "image",
|
||||
width: "170px",
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<a target="_blank" rel="noreferrer" href={text}>
|
||||
<img src={text} alt={text} width={150} />
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("payment:Currency"),
|
||||
dataIndex: "currency",
|
||||
key: "currency",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return Setting.getCurrencyWithFlag(text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("product:Price"),
|
||||
dataIndex: "price",
|
||||
key: "price",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: i18next.t("product:Quantity"),
|
||||
dataIndex: "quantity",
|
||||
key: "quantity",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Action"),
|
||||
dataIndex: "",
|
||||
key: "op",
|
||||
width: "160px",
|
||||
fixed: Setting.isMobile() ? false : "right",
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div style={{display: "flex", flexWrap: "wrap", gap: "8px"}}>
|
||||
<Button type="primary" onClick={() => this.props.history.push(`/products/${owner}/${record.name}/buy`)}>
|
||||
{i18next.t("product:Buy")}
|
||||
</Button>
|
||||
<PopconfirmModal
|
||||
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
|
||||
onConfirm={() => this.deleteCart(record)}
|
||||
>
|
||||
</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={carts}
|
||||
rowKey={(record, index) => `${record.name}-${index}`}
|
||||
size="middle"
|
||||
bordered
|
||||
pagination={paginationProps}
|
||||
title={() => {
|
||||
return (
|
||||
<div>
|
||||
{i18next.t("general:Carts")}
|
||||
<Button type="primary" size="small" onClick={() => this.props.history.push("/product-store")}>{i18next.t("general:Add")}</Button>
|
||||
|
||||
<Button size="small">{i18next.t("general:Place Order")}</Button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
loading={this.state.loading}
|
||||
onChange={this.handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
fetch = (params = {}) => {
|
||||
this.setState({loading: true});
|
||||
const organizationName = this.props.account.owner;
|
||||
const userName = this.props.account.name;
|
||||
|
||||
UserBackend.getUser(organizationName, userName)
|
||||
.then((res) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
});
|
||||
if (res.status === "ok") {
|
||||
const cartData = res.data.cart || [];
|
||||
this.setState({
|
||||
data: cartData,
|
||||
user: res.data,
|
||||
pagination: {
|
||||
...params.pagination,
|
||||
total: cartData.length,
|
||||
},
|
||||
searchText: params.searchText,
|
||||
searchedColumn: params.searchedColumn,
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", res.msg);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
});
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default CartListPage;
|
||||
@@ -64,6 +64,7 @@ import ProductListPage from "./ProductListPage";
|
||||
import ProductStorePage from "./ProductStorePage";
|
||||
import ProductEditPage from "./ProductEditPage";
|
||||
import ProductBuyPage from "./ProductBuyPage";
|
||||
import CartListPage from "./CartListPage";
|
||||
import OrderListPage from "./OrderListPage";
|
||||
import OrderEditPage from "./OrderEditPage";
|
||||
import OrderPayPage from "./OrderPayPage";
|
||||
@@ -349,6 +350,7 @@ function ManagementPage(props) {
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business & Payments")}</Link>, "/business", <DollarTwoTone twoToneColor={twoToneColor} />, [
|
||||
Setting.getItem(<Link to="/product-store">{i18next.t("general:Product Store")}</Link>, "/product-store"),
|
||||
Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, "/products"),
|
||||
Setting.getItem(<Link to="/carts">{i18next.t("general:Carts")}</Link>, "/carts"),
|
||||
Setting.getItem(<Link to="/orders">{i18next.t("general:Orders")}</Link>, "/orders"),
|
||||
Setting.getItem(<Link to="/payments">{i18next.t("general:Payments")}</Link>, "/payments"),
|
||||
Setting.getItem(<Link to="/plans">{i18next.t("general:Plans")}</Link>, "/plans"),
|
||||
@@ -489,6 +491,7 @@ function ManagementPage(props) {
|
||||
<Route exact path="/products" render={(props) => renderLoginIfNotLoggedIn(<ProductListPage account={account} {...props} />)} />
|
||||
<Route exact path="/products/:organizationName/:productName" render={(props) => renderLoginIfNotLoggedIn(<ProductEditPage account={account} {...props} />)} />
|
||||
<Route exact path="/products/:organizationName/:productName/buy" render={(props) => renderLoginIfNotLoggedIn(<ProductBuyPage account={account} {...props} />)} />
|
||||
<Route exact path="/carts" render={(props) => renderLoginIfNotLoggedIn(<CartListPage account={account} {...props} />)} />
|
||||
<Route exact path="/orders" render={(props) => renderLoginIfNotLoggedIn(<OrderListPage account={account} {...props} />)} />
|
||||
<Route exact path="/orders/:organizationName/:orderName" render={(props) => renderLoginIfNotLoggedIn(<OrderEditPage account={account} {...props} />)} />
|
||||
<Route exact path="/orders/:organizationName/:orderName/pay" render={(props) => renderLoginIfNotLoggedIn(<OrderPayPage account={account} {...props} />)} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as ProductBackend from "./backend/ProductBackend";
|
||||
import * as PlanBackend from "./backend/PlanBackend";
|
||||
import * as PricingBackend from "./backend/PricingBackend";
|
||||
import * as OrderBackend from "./backend/OrderBackend";
|
||||
import * as UserBackend from "./backend/UserBackend";
|
||||
import * as Setting from "./Setting";
|
||||
|
||||
class ProductBuyPage extends React.Component {
|
||||
@@ -37,6 +38,7 @@ class ProductBuyPage extends React.Component {
|
||||
pricing: props?.pricing ?? null,
|
||||
plan: null,
|
||||
isPlacingOrder: false,
|
||||
isAddingToCart: false,
|
||||
customPrice: 0,
|
||||
};
|
||||
}
|
||||
@@ -129,6 +131,85 @@ class ProductBuyPage extends React.Component {
|
||||
return `${Setting.getCurrencySymbol(product?.currency)}${product?.price} (${Setting.getCurrencyText(product)})`;
|
||||
}
|
||||
|
||||
addToCart(product) {
|
||||
if (this.state.isAddingToCart) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({isAddingToCart: true});
|
||||
|
||||
const userOwner = this.props.account.owner;
|
||||
const userName = this.props.account.name;
|
||||
|
||||
UserBackend.getUser(userOwner, userName)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
const user = res.data;
|
||||
const cart = user.cart || [];
|
||||
|
||||
let actualPrice = product.price;
|
||||
if (product.isRecharge) {
|
||||
actualPrice = this.state.customPrice;
|
||||
if (actualPrice <= 0) {
|
||||
Setting.showMessage("error", i18next.t("product:Custom price should be greater than zero"));
|
||||
this.setState({isAddingToCart: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (cart.length > 0) {
|
||||
const firstItem = cart[0];
|
||||
if (firstItem.currency && product.currency && firstItem.currency !== product.currency) {
|
||||
Setting.showMessage("error", i18next.t("product:The currency of the product you are adding is different from the currency of the items in the cart"));
|
||||
this.setState({isAddingToCart: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const existingItemIndex = cart.findIndex(item => item.name === product.name && item.price === actualPrice);
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
cart[existingItemIndex].quantity += 1;
|
||||
} else {
|
||||
const newProductInfo = {
|
||||
name: product.name,
|
||||
displayName: product.displayName,
|
||||
image: product.image,
|
||||
detail: product.detail,
|
||||
price: actualPrice,
|
||||
currency: product.currency,
|
||||
quantity: 1,
|
||||
isRecharge: product.isRecharge,
|
||||
};
|
||||
cart.push(newProductInfo);
|
||||
}
|
||||
|
||||
user.cart = cart;
|
||||
UserBackend.updateUser(user.owner, user.name, user)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully added"));
|
||||
} else {
|
||||
Setting.showMessage("error", res.msg);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({isAddingToCart: false});
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${res.msg}`);
|
||||
this.setState({isAddingToCart: false});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
this.setState({isAddingToCart: false});
|
||||
});
|
||||
}
|
||||
|
||||
placeOrder(product) {
|
||||
this.setState({
|
||||
isPlacingOrder: true,
|
||||
@@ -229,9 +310,10 @@ class ProductBuyPage extends React.Component {
|
||||
const hasOptions = product.rechargeOptions && product.rechargeOptions.length > 0;
|
||||
const disableCustom = product.disableCustomRecharge;
|
||||
const isRechargeUnpurchasable = product.isRecharge && !hasOptions && disableCustom;
|
||||
const isSubscription = product.tag === "Subscription";
|
||||
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center", gap: "20px"}}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
@@ -248,6 +330,24 @@ class ProductBuyPage extends React.Component {
|
||||
>
|
||||
{i18next.t("order:Place Order")}
|
||||
</Button>
|
||||
{!isSubscription && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
style={{
|
||||
height: "50px",
|
||||
fontSize: "18px",
|
||||
borderRadius: "30px",
|
||||
paddingLeft: "30px",
|
||||
paddingRight: "30px",
|
||||
}}
|
||||
onClick={() => this.addToCart(product)}
|
||||
disabled={isRechargeUnpurchasable || this.state.isAddingToCart}
|
||||
loading={this.state.isAddingToCart}
|
||||
>
|
||||
{i18next.t("product:Add to cart")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import React from "react";
|
||||
import {Button, Card, Col, Row, Tag, Typography} from "antd";
|
||||
import * as Setting from "./Setting";
|
||||
import * as ProductBackend from "./backend/ProductBackend";
|
||||
import * as UserBackend from "./backend/UserBackend";
|
||||
import i18next from "i18next";
|
||||
|
||||
const {Text, Title} = Typography;
|
||||
@@ -28,6 +29,7 @@ class ProductStorePage extends React.Component {
|
||||
this.state = {
|
||||
products: [],
|
||||
loading: true,
|
||||
isAddingToCart: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,11 +59,83 @@ class ProductStorePage extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
addToCart(product) {
|
||||
if (this.state.isAddingToCart) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({isAddingToCart: true});
|
||||
|
||||
const userOwner = this.props.account.owner;
|
||||
const userName = this.props.account.name;
|
||||
|
||||
UserBackend.getUser(userOwner, userName)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
const user = res.data;
|
||||
const cart = user.cart || [];
|
||||
|
||||
if (cart.length > 0) {
|
||||
const firstItem = cart[0];
|
||||
|
||||
if (firstItem.currency && product.currency && firstItem.currency !== product.currency) {
|
||||
Setting.showMessage("error", i18next.t("product:The currency of the product you are adding is different from the currency of the items in the cart"));
|
||||
this.setState({isAddingToCart: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const existingItemIndex = cart.findIndex(item => item.name === product.name && item.price === product.price);
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
cart[existingItemIndex].quantity += 1;
|
||||
} else {
|
||||
const newCartProductInfo = {
|
||||
name: product.name,
|
||||
displayName: product.displayName,
|
||||
image: product.image,
|
||||
detail: product.detail,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: 1,
|
||||
isRecharge: product.isRecharge || false,
|
||||
};
|
||||
cart.push(newCartProductInfo);
|
||||
}
|
||||
|
||||
user.cart = cart;
|
||||
UserBackend.updateUser(user.owner, user.name, user)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully added"));
|
||||
} else {
|
||||
Setting.showMessage("error", res.msg);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({isAddingToCart: false});
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", res.msg);
|
||||
this.setState({isAddingToCart: false});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
this.setState({isAddingToCart: false});
|
||||
});
|
||||
}
|
||||
|
||||
handleBuyProduct(product) {
|
||||
this.props.history.push(`/products/${product.owner}/${product.name}/buy`);
|
||||
}
|
||||
|
||||
renderProductCard(product) {
|
||||
const isSubscription = product.tag === "Subscription";
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={`${product.owner}/${product.name}`} style={{marginBottom: "20px"}}>
|
||||
<Card
|
||||
@@ -78,16 +152,32 @@ class ProductStorePage extends React.Component {
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
<Button
|
||||
key="buy"
|
||||
type="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.handleBuyProduct(product);
|
||||
}}
|
||||
>
|
||||
{i18next.t("product:Buy")}
|
||||
</Button>,
|
||||
<div key="actions" style={{display: "flex", justifyContent: "center", gap: "10px", width: "100%", padding: "0 10px"}} onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
key="buy"
|
||||
type="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.handleBuyProduct(product);
|
||||
}}
|
||||
>
|
||||
{i18next.t("product:Buy")}
|
||||
</Button>
|
||||
{!product.isRecharge && !isSubscription && (
|
||||
<Button
|
||||
key="add"
|
||||
type="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.addToCart(product);
|
||||
}}
|
||||
disabled={this.state.isAddingToCart}
|
||||
loading={this.state.isAddingToCart}
|
||||
>
|
||||
{i18next.t("product:Add to cart")}
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
]}
|
||||
bodyStyle={{flex: 1, display: "flex", flexDirection: "column"}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user