feat: add the shopping cart page (#4855)

This commit is contained in:
IsAurora6
2026-01-19 12:12:15 +08:00
committed by GitHub
parent 4236160fa7
commit 039c12afa3
6 changed files with 422 additions and 15 deletions

View File

@@ -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) {

View File

@@ -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
View 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")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={() => this.props.history.push("/product-store")}>{i18next.t("general:Add")}</Button>
&nbsp;&nbsp;
<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;

View File

@@ -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} />)} />

View File

@@ -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>
);
}

View File

@@ -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"}}
>