@blocklet/payment-react 1.20.15 → 1.20.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/es/locales/en.js CHANGED
@@ -220,9 +220,19 @@ export default flat({
220
220
  remove: "Remove from order"
221
221
  },
222
222
  credit: {
223
- oneTimeInfo: "You will receive {amount} {symbol} credits after payment",
224
- recurringInfo: "You will receive {amount} {symbol} credits {period}",
225
- expiresIn: "credits have a validity period of {duration} {unit}"
223
+ normal: {
224
+ oneTime: "You will receive {amount} {symbol} after payment.",
225
+ oneTimeWithExpiry: "You will receive {amount} {symbol} after payment, valid for {duration} {unit}.",
226
+ recurring: "You will receive {amount} {symbol} {period}.",
227
+ recurringWithExpiry: "You will receive {amount} {symbol} {period}, valid for {duration} {unit}."
228
+ },
229
+ pending: {
230
+ notEnough: "Your outstanding balance is {amount} {symbol}. To settle it, a minimum purchase of {quantity} units is required.",
231
+ oneTimeEnough: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol}, resulting in a net balance of {availableAmount} {symbol} after the deduction.",
232
+ oneTimeEnoughWithExpiry: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} (valid for {duration} {unit}), resulting in a net balance of {availableAmount} {symbol} after the deduction.",
233
+ recurringEnough: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} {period}, resulting in a net balance of {availableAmount} {symbol} after the deduction.",
234
+ recurringEnoughWithExpiry: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} {period} (valid for {duration} {unit}), resulting in a net balance of {availableAmount} {symbol} after the deduction."
235
+ }
226
236
  },
227
237
  expired: {
228
238
  title: "Expired Link",
package/es/locales/zh.js CHANGED
@@ -257,9 +257,19 @@ export default flat({
257
257
  }
258
258
  },
259
259
  credit: {
260
- oneTimeInfo: "\u4ED8\u6B3E\u5B8C\u6210\u540E\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6",
261
- recurringInfo: "\u60A8\u5C06{period}\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6",
262
- expiresIn: "\u989D\u5EA6\u6709\u6548\u671F\u4E3A {duration} {unit}"
260
+ normal: {
261
+ oneTime: "\u4ED8\u6B3E\u5B8C\u6210\u540E\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\u3002",
262
+ oneTimeWithExpiry: "\u4ED8\u6B3E\u5B8C\u6210\u540E\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\uFF0C\u6709\u6548\u671F {duration} {unit}\u3002",
263
+ recurring: "\u4ED8\u6B3E\u5B8C\u6210\u540E\uFF0C{period}\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\u3002",
264
+ recurringWithExpiry: "\u4ED8\u6B3E\u5B8C\u6210\u540E\uFF0C{period}\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\uFF0C\u6709\u6548\u671F {duration} {unit}\u3002"
265
+ },
266
+ pending: {
267
+ notEnough: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u81F3\u5C11\u9700\u8981\u8D2D\u4E70 {quantity} \u6570\u91CF\u3002",
268
+ oneTimeEnough: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002",
269
+ oneTimeEnoughWithExpiry: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002",
270
+ recurringEnough: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E{period}\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002",
271
+ recurringEnoughWithExpiry: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E{period}\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002"
272
+ }
263
273
  },
264
274
  emptyItems: {
265
275
  title: "\u6CA1\u6709\u4EFB\u4F55\u8D2D\u4E70\u9879\u76EE",
@@ -2,7 +2,9 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
3
3
  import { Box, Stack, Typography, IconButton, TextField, Alert, Chip } from "@mui/material";
4
4
  import { Add, Remove, LocalOffer } from "@mui/icons-material";
5
- import { useMemo, useState } from "react";
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { useRequest } from "ahooks";
7
+ import { BN, fromTokenToUnit } from "@ocap/util";
6
8
  import Status from "../components/status.js";
7
9
  import Switch from "../components/switch-button.js";
8
10
  import {
@@ -13,11 +15,37 @@ import {
13
15
  formatQuantityInventory,
14
16
  formatRecurring,
15
17
  formatUpsellSaving,
16
- formatAmount
18
+ formatAmount,
19
+ formatBNStr
17
20
  } from "../libs/util.js";
18
21
  import ProductCard from "./product-card.js";
19
22
  import dayjs from "../libs/dayjs.js";
20
23
  import { usePaymentContext } from "../contexts/payment.js";
24
+ const getRecommendedQuantityFromUrl = (priceId) => {
25
+ try {
26
+ const urlParams = new URLSearchParams(window.location.search);
27
+ const recommendedQuantity = urlParams.get(`qty_${priceId}`) || urlParams.get("qty");
28
+ return recommendedQuantity ? Math.max(1, parseInt(recommendedQuantity, 10)) : void 0;
29
+ } catch {
30
+ return void 0;
31
+ }
32
+ };
33
+ const getUserQuantityPreference = (userDid, priceId) => {
34
+ try {
35
+ const key = `quantity_preference_${userDid}_${priceId}`;
36
+ const stored = localStorage.getItem(key);
37
+ return stored ? Math.max(1, parseInt(stored, 10)) : void 0;
38
+ } catch {
39
+ return void 0;
40
+ }
41
+ };
42
+ const saveUserQuantityPreference = (userDid, priceId, quantity) => {
43
+ try {
44
+ const key = `quantity_preference_${userDid}_${priceId}`;
45
+ localStorage.setItem(key, quantity.toString());
46
+ } catch {
47
+ }
48
+ };
21
49
  export default function ProductItem({
22
50
  item,
23
51
  items,
@@ -35,7 +63,7 @@ export default function ProductItem({
35
63
  showFeatures = false
36
64
  }) {
37
65
  const { t, locale } = useLocaleContext();
38
- const { settings, setPayable } = usePaymentContext();
66
+ const { settings, setPayable, session, api } = usePaymentContext();
39
67
  const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays }, locale);
40
68
  const saving = formatUpsellSaving(items, currency);
41
69
  const metered = item.price?.recurring?.usage_type === "metered" ? t("common.metered") : "";
@@ -45,12 +73,52 @@ export default function ProductItem({
45
73
  const creditCurrency = isCreditProduct ? findCurrency(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? "") : null;
46
74
  const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
47
75
  const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || "days";
48
- const [localQuantity, setLocalQuantity] = useState(item.quantity);
76
+ const userDid = session?.user?.did;
77
+ const { data: pendingAmount } = useRequest(
78
+ async () => {
79
+ if (!isCreditProduct || !userDid) return null;
80
+ try {
81
+ const { data } = await api.get("/api/meter-events/pending-amount", {
82
+ params: {
83
+ customer_id: userDid,
84
+ currency_id: creditCurrency?.id
85
+ }
86
+ });
87
+ return data?.[creditCurrency?.id || ""];
88
+ } catch (error) {
89
+ console.warn("Failed to fetch pending amount:", error);
90
+ return null;
91
+ }
92
+ },
93
+ {
94
+ refreshDeps: [isCreditProduct, userDid, creditCurrency?.id]
95
+ }
96
+ );
97
+ const getInitialQuantity = () => {
98
+ const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
99
+ if (urlQuantity && urlQuantity > 0) {
100
+ return urlQuantity;
101
+ }
102
+ if (userDid) {
103
+ const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
104
+ if (preferredQuantity && preferredQuantity > 0) {
105
+ return preferredQuantity;
106
+ }
107
+ }
108
+ return item.quantity;
109
+ };
110
+ const [localQuantity, setLocalQuantity] = useState(getInitialQuantity());
49
111
  const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
50
112
  const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
51
113
  const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
52
114
  const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
53
115
  const localQuantityNum = localQuantity || 0;
116
+ useEffect(() => {
117
+ const initialQuantity = getInitialQuantity();
118
+ if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
119
+ onQuantityChange(item.price_id, initialQuantity);
120
+ }
121
+ }, []);
54
122
  const handleQuantityChange = (newQuantity) => {
55
123
  if (!newQuantity) {
56
124
  setLocalQuantity(void 0);
@@ -64,6 +132,9 @@ export default function ProductItem({
64
132
  }
65
133
  setLocalQuantity(newQuantity);
66
134
  onQuantityChange(item.price_id, newQuantity);
135
+ if (userDid && newQuantity > 0) {
136
+ saveUserQuantityPreference(userDid, item.price.id, newQuantity);
137
+ }
67
138
  }
68
139
  };
69
140
  const handleQuantityIncrease = () => {
@@ -84,27 +155,59 @@ export default function ProductItem({
84
155
  };
85
156
  const formatCreditInfo = () => {
86
157
  if (!isCreditProduct) return null;
158
+ const totalCreditStr = formatNumber(creditAmount * (localQuantity || 0));
159
+ const currencySymbol = creditCurrency?.symbol || "Credits";
160
+ const hasPendingAmount = pendingAmount && new BN(pendingAmount || "0").gt(new BN(0));
87
161
  const isRecurring = item.price.type === "recurring";
88
- const totalCredit = formatNumber(creditAmount * (localQuantity || 0));
89
- let message = "";
90
- if (isRecurring) {
91
- message = t("payment.checkout.credit.recurringInfo", {
92
- amount: totalCredit,
162
+ const hasExpiry = validDuration && validDuration > 0;
163
+ const buildBaseParams = () => ({
164
+ amount: totalCreditStr,
165
+ symbol: currencySymbol,
166
+ ...hasExpiry && {
167
+ duration: validDuration,
168
+ unit: t(`common.${validDurationUnit}`)
169
+ },
170
+ ...isRecurring && {
93
171
  period: formatRecurring(item.price.recurring, true, "per", locale)
94
- });
95
- } else {
96
- message = t("payment.checkout.credit.oneTimeInfo", {
97
- amount: totalCredit,
98
- symbol: creditCurrency?.symbol || "Credits"
99
- });
100
- }
101
- if (validDuration && validDuration > 0) {
102
- message += `\uFF0C${t("payment.checkout.credit.expiresIn", {
172
+ }
173
+ });
174
+ const buildPendingParams = (pendingBN, availableAmount) => ({
175
+ amount: formatBNStr(pendingBN.toString(), creditCurrency?.decimal || 2),
176
+ symbol: currencySymbol,
177
+ totalAmount: totalCreditStr,
178
+ ...availableAmount && {
179
+ availableAmount: formatBNStr(availableAmount, creditCurrency?.decimal || 2)
180
+ },
181
+ ...hasExpiry && {
103
182
  duration: validDuration,
104
183
  unit: t(`common.${validDurationUnit}`)
105
- })}`;
184
+ },
185
+ ...isRecurring && {
186
+ period: formatRecurring(item.price.recurring, true, "per", locale)
187
+ }
188
+ });
189
+ const getLocaleKey = (category, type2) => {
190
+ const suffix = hasExpiry ? "WithExpiry" : "";
191
+ return `payment.checkout.credit.${category}.${type2}${suffix}`;
192
+ };
193
+ if (!hasPendingAmount) {
194
+ const type2 = isRecurring ? "recurring" : "oneTime";
195
+ return t(getLocaleKey("normal", type2), buildBaseParams());
196
+ }
197
+ const pendingAmountBN = new BN(pendingAmount || "0");
198
+ const creditAmountBN = fromTokenToUnit(new BN(creditAmount), creditCurrency?.decimal || 2);
199
+ const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
200
+ const currentPurchaseCreditBN = creditAmountBN.mul(new BN(localQuantity || 0));
201
+ const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
202
+ if (!new BN(actualAvailable).gt(new BN(0))) {
203
+ return t("payment.checkout.credit.pending.notEnough", {
204
+ amount: formatBNStr(pendingAmountBN.toString(), creditCurrency?.decimal || 2),
205
+ symbol: currencySymbol,
206
+ quantity: formatNumber(minQuantityNeeded)
207
+ });
106
208
  }
107
- return message;
209
+ const type = isRecurring ? "recurringEnough" : "oneTimeEnough";
210
+ return t(getLocaleKey("pending", type), buildPendingParams(pendingAmountBN, actualAvailable));
108
211
  };
109
212
  const primaryText = useMemo(() => {
110
213
  const price = item.upsell_price || item.price || {};
@@ -228,7 +228,7 @@ const Div = styled("div")`
228
228
  content: '';
229
229
  height: 100px;
230
230
  position: absolute;
231
- background: ${(props) => props.theme.palette.background.paper};
231
+ background: ${(props) => props.theme.palette.background.default};
232
232
  transform: rotate(-45deg);
233
233
  }
234
234
  .check-icon .icon-line {
@@ -272,7 +272,7 @@ const Div = styled("div")`
272
272
  height: 85px;
273
273
  position: absolute;
274
274
  transform: rotate(-45deg);
275
- background-color: ${(props) => props.theme.palette.background.paper};
275
+ background-color: ${(props) => props.theme.palette.background.default};
276
276
  }
277
277
 
278
278
  @keyframes rotate-circle {
package/lib/locales/en.js CHANGED
@@ -227,9 +227,19 @@ module.exports = (0, _flat.default)({
227
227
  remove: "Remove from order"
228
228
  },
229
229
  credit: {
230
- oneTimeInfo: "You will receive {amount} {symbol} credits after payment",
231
- recurringInfo: "You will receive {amount} {symbol} credits {period}",
232
- expiresIn: "credits have a validity period of {duration} {unit}"
230
+ normal: {
231
+ oneTime: "You will receive {amount} {symbol} after payment.",
232
+ oneTimeWithExpiry: "You will receive {amount} {symbol} after payment, valid for {duration} {unit}.",
233
+ recurring: "You will receive {amount} {symbol} {period}.",
234
+ recurringWithExpiry: "You will receive {amount} {symbol} {period}, valid for {duration} {unit}."
235
+ },
236
+ pending: {
237
+ notEnough: "Your outstanding balance is {amount} {symbol}. To settle it, a minimum purchase of {quantity} units is required.",
238
+ oneTimeEnough: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol}, resulting in a net balance of {availableAmount} {symbol} after the deduction.",
239
+ oneTimeEnoughWithExpiry: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} (valid for {duration} {unit}), resulting in a net balance of {availableAmount} {symbol} after the deduction.",
240
+ recurringEnough: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} {period}, resulting in a net balance of {availableAmount} {symbol} after the deduction.",
241
+ recurringEnoughWithExpiry: "Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} {period} (valid for {duration} {unit}), resulting in a net balance of {availableAmount} {symbol} after the deduction."
242
+ }
233
243
  },
234
244
  expired: {
235
245
  title: "Expired Link",
package/lib/locales/zh.js CHANGED
@@ -264,9 +264,19 @@ module.exports = (0, _flat.default)({
264
264
  }
265
265
  },
266
266
  credit: {
267
- oneTimeInfo: "\u4ED8\u6B3E\u5B8C\u6210\u540E\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6",
268
- recurringInfo: "\u60A8\u5C06{period}\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6",
269
- expiresIn: "\u989D\u5EA6\u6709\u6548\u671F\u4E3A {duration} {unit}"
267
+ normal: {
268
+ oneTime: "\u4ED8\u6B3E\u5B8C\u6210\u540E\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\u3002",
269
+ oneTimeWithExpiry: "\u4ED8\u6B3E\u5B8C\u6210\u540E\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\uFF0C\u6709\u6548\u671F {duration} {unit}\u3002",
270
+ recurring: "\u4ED8\u6B3E\u5B8C\u6210\u540E\uFF0C{period}\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\u3002",
271
+ recurringWithExpiry: "\u4ED8\u6B3E\u5B8C\u6210\u540E\uFF0C{period}\u60A8\u5C06\u83B7\u5F97 {amount} {symbol} \u989D\u5EA6\uFF0C\u6709\u6548\u671F {duration} {unit}\u3002"
272
+ },
273
+ pending: {
274
+ notEnough: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u81F3\u5C11\u9700\u8981\u8D2D\u4E70 {quantity} \u6570\u91CF\u3002",
275
+ oneTimeEnough: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002",
276
+ oneTimeEnoughWithExpiry: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002",
277
+ recurringEnough: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E{period}\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002",
278
+ recurringEnoughWithExpiry: "\u60A8\u5F53\u524D\u6B20\u8D39 {amount} {symbol}\uFF0C\u4ED8\u6B3E\u540E{period}\u5C06\u83B7\u5F97 {totalAmount} {symbol} \u989D\u5EA6\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\uFF0C\u6263\u9664\u6B20\u8D39\u540E\u51C0\u4F59\u989D\u4E3A {availableAmount} {symbol}\u3002"
279
+ }
270
280
  },
271
281
  emptyItems: {
272
282
  title: "\u6CA1\u6709\u4EFB\u4F55\u8D2D\u4E70\u9879\u76EE",
@@ -9,13 +9,39 @@ var _context = require("@arcblock/ux/lib/Locale/context");
9
9
  var _material = require("@mui/material");
10
10
  var _iconsMaterial = require("@mui/icons-material");
11
11
  var _react = require("react");
12
+ var _ahooks = require("ahooks");
13
+ var _util = require("@ocap/util");
12
14
  var _status = _interopRequireDefault(require("../components/status"));
13
15
  var _switchButton = _interopRequireDefault(require("../components/switch-button"));
14
- var _util = require("../libs/util");
16
+ var _util2 = require("../libs/util");
15
17
  var _productCard = _interopRequireDefault(require("./product-card"));
16
18
  var _dayjs = _interopRequireDefault(require("../libs/dayjs"));
17
19
  var _payment = require("../contexts/payment");
18
20
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
21
+ const getRecommendedQuantityFromUrl = priceId => {
22
+ try {
23
+ const urlParams = new URLSearchParams(window.location.search);
24
+ const recommendedQuantity = urlParams.get(`qty_${priceId}`) || urlParams.get("qty");
25
+ return recommendedQuantity ? Math.max(1, parseInt(recommendedQuantity, 10)) : void 0;
26
+ } catch {
27
+ return void 0;
28
+ }
29
+ };
30
+ const getUserQuantityPreference = (userDid, priceId) => {
31
+ try {
32
+ const key = `quantity_preference_${userDid}_${priceId}`;
33
+ const stored = localStorage.getItem(key);
34
+ return stored ? Math.max(1, parseInt(stored, 10)) : void 0;
35
+ } catch {
36
+ return void 0;
37
+ }
38
+ };
39
+ const saveUserQuantityPreference = (userDid, priceId, quantity) => {
40
+ try {
41
+ const key = `quantity_preference_${userDid}_${priceId}`;
42
+ localStorage.setItem(key, quantity.toString());
43
+ } catch {}
44
+ };
19
45
  function ProductItem({
20
46
  item,
21
47
  items,
@@ -39,26 +65,69 @@ function ProductItem({
39
65
  } = (0, _context.useLocaleContext)();
40
66
  const {
41
67
  settings,
42
- setPayable
68
+ setPayable,
69
+ session,
70
+ api
43
71
  } = (0, _payment.usePaymentContext)();
44
- const pricing = (0, _util.formatLineItemPricing)(item, currency, {
72
+ const pricing = (0, _util2.formatLineItemPricing)(item, currency, {
45
73
  trialEnd,
46
74
  trialInDays
47
75
  }, locale);
48
- const saving = (0, _util.formatUpsellSaving)(items, currency);
76
+ const saving = (0, _util2.formatUpsellSaving)(items, currency);
49
77
  const metered = item.price?.recurring?.usage_type === "metered" ? t("common.metered") : "";
50
78
  const canUpsell = mode === "normal" && items.length === 1;
51
79
  const isCreditProduct = item.price.product?.type === "credit" && item.price.metadata?.credit_config?.credit_amount;
52
80
  const creditAmount = isCreditProduct ? Number(item.price.metadata.credit_config.credit_amount) : 0;
53
- const creditCurrency = isCreditProduct ? (0, _util.findCurrency)(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? "") : null;
81
+ const creditCurrency = isCreditProduct ? (0, _util2.findCurrency)(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? "") : null;
54
82
  const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
55
83
  const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || "days";
56
- const [localQuantity, setLocalQuantity] = (0, _react.useState)(item.quantity);
84
+ const userDid = session?.user?.did;
85
+ const {
86
+ data: pendingAmount
87
+ } = (0, _ahooks.useRequest)(async () => {
88
+ if (!isCreditProduct || !userDid) return null;
89
+ try {
90
+ const {
91
+ data
92
+ } = await api.get("/api/meter-events/pending-amount", {
93
+ params: {
94
+ customer_id: userDid,
95
+ currency_id: creditCurrency?.id
96
+ }
97
+ });
98
+ return data?.[creditCurrency?.id || ""];
99
+ } catch (error) {
100
+ console.warn("Failed to fetch pending amount:", error);
101
+ return null;
102
+ }
103
+ }, {
104
+ refreshDeps: [isCreditProduct, userDid, creditCurrency?.id]
105
+ });
106
+ const getInitialQuantity = () => {
107
+ const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
108
+ if (urlQuantity && urlQuantity > 0) {
109
+ return urlQuantity;
110
+ }
111
+ if (userDid) {
112
+ const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
113
+ if (preferredQuantity && preferredQuantity > 0) {
114
+ return preferredQuantity;
115
+ }
116
+ }
117
+ return item.quantity;
118
+ };
119
+ const [localQuantity, setLocalQuantity] = (0, _react.useState)(getInitialQuantity());
57
120
  const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
58
121
  const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
59
122
  const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
60
123
  const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
61
124
  const localQuantityNum = localQuantity || 0;
125
+ (0, _react.useEffect)(() => {
126
+ const initialQuantity = getInitialQuantity();
127
+ if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
128
+ onQuantityChange(item.price_id, initialQuantity);
129
+ }
130
+ }, []);
62
131
  const handleQuantityChange = newQuantity => {
63
132
  if (!newQuantity) {
64
133
  setLocalQuantity(void 0);
@@ -67,11 +136,14 @@ function ProductItem({
67
136
  }
68
137
  setPayable(true);
69
138
  if (newQuantity >= minQuantity && newQuantity <= maxQuantity) {
70
- if ((0, _util.formatQuantityInventory)(item.price, newQuantity, locale)) {
139
+ if ((0, _util2.formatQuantityInventory)(item.price, newQuantity, locale)) {
71
140
  return;
72
141
  }
73
142
  setLocalQuantity(newQuantity);
74
143
  onQuantityChange(item.price_id, newQuantity);
144
+ if (userDid && newQuantity > 0) {
145
+ saveUserQuantityPreference(userDid, item.price.id, newQuantity);
146
+ }
75
147
  }
76
148
  };
77
149
  const handleQuantityIncrease = () => {
@@ -92,38 +164,70 @@ function ProductItem({
92
164
  };
93
165
  const formatCreditInfo = () => {
94
166
  if (!isCreditProduct) return null;
167
+ const totalCreditStr = (0, _util2.formatNumber)(creditAmount * (localQuantity || 0));
168
+ const currencySymbol = creditCurrency?.symbol || "Credits";
169
+ const hasPendingAmount = pendingAmount && new _util.BN(pendingAmount || "0").gt(new _util.BN(0));
95
170
  const isRecurring = item.price.type === "recurring";
96
- const totalCredit = (0, _util.formatNumber)(creditAmount * (localQuantity || 0));
97
- let message = "";
98
- if (isRecurring) {
99
- message = t("payment.checkout.credit.recurringInfo", {
100
- amount: totalCredit,
101
- period: (0, _util.formatRecurring)(item.price.recurring, true, "per", locale)
102
- });
103
- } else {
104
- message = t("payment.checkout.credit.oneTimeInfo", {
105
- amount: totalCredit,
106
- symbol: creditCurrency?.symbol || "Credits"
107
- });
108
- }
109
- if (validDuration && validDuration > 0) {
110
- message += `\uFF0C${t("payment.checkout.credit.expiresIn", {
171
+ const hasExpiry = validDuration && validDuration > 0;
172
+ const buildBaseParams = () => ({
173
+ amount: totalCreditStr,
174
+ symbol: currencySymbol,
175
+ ...(hasExpiry && {
111
176
  duration: validDuration,
112
177
  unit: t(`common.${validDurationUnit}`)
113
- })}`;
178
+ }),
179
+ ...(isRecurring && {
180
+ period: (0, _util2.formatRecurring)(item.price.recurring, true, "per", locale)
181
+ })
182
+ });
183
+ const buildPendingParams = (pendingBN, availableAmount) => ({
184
+ amount: (0, _util2.formatBNStr)(pendingBN.toString(), creditCurrency?.decimal || 2),
185
+ symbol: currencySymbol,
186
+ totalAmount: totalCreditStr,
187
+ ...(availableAmount && {
188
+ availableAmount: (0, _util2.formatBNStr)(availableAmount, creditCurrency?.decimal || 2)
189
+ }),
190
+ ...(hasExpiry && {
191
+ duration: validDuration,
192
+ unit: t(`common.${validDurationUnit}`)
193
+ }),
194
+ ...(isRecurring && {
195
+ period: (0, _util2.formatRecurring)(item.price.recurring, true, "per", locale)
196
+ })
197
+ });
198
+ const getLocaleKey = (category, type2) => {
199
+ const suffix = hasExpiry ? "WithExpiry" : "";
200
+ return `payment.checkout.credit.${category}.${type2}${suffix}`;
201
+ };
202
+ if (!hasPendingAmount) {
203
+ const type2 = isRecurring ? "recurring" : "oneTime";
204
+ return t(getLocaleKey("normal", type2), buildBaseParams());
205
+ }
206
+ const pendingAmountBN = new _util.BN(pendingAmount || "0");
207
+ const creditAmountBN = (0, _util.fromTokenToUnit)(new _util.BN(creditAmount), creditCurrency?.decimal || 2);
208
+ const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new _util.BN(100)).div(creditAmountBN).toNumber() / 100);
209
+ const currentPurchaseCreditBN = creditAmountBN.mul(new _util.BN(localQuantity || 0));
210
+ const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
211
+ if (!new _util.BN(actualAvailable).gt(new _util.BN(0))) {
212
+ return t("payment.checkout.credit.pending.notEnough", {
213
+ amount: (0, _util2.formatBNStr)(pendingAmountBN.toString(), creditCurrency?.decimal || 2),
214
+ symbol: currencySymbol,
215
+ quantity: (0, _util2.formatNumber)(minQuantityNeeded)
216
+ });
114
217
  }
115
- return message;
218
+ const type = isRecurring ? "recurringEnough" : "oneTimeEnough";
219
+ return t(getLocaleKey("pending", type), buildPendingParams(pendingAmountBN, actualAvailable));
116
220
  };
117
221
  const primaryText = (0, _react.useMemo)(() => {
118
222
  const price = item.upsell_price || item.price || {};
119
223
  const isRecurring = price?.type === "recurring" && price?.recurring;
120
224
  const trial = trialInDays > 0 || trialEnd > (0, _dayjs.default)().unix();
121
225
  if (isRecurring && !trial && price?.recurring?.usage_type !== "metered") {
122
- return `${pricing.primary} ${price.recurring ? (0, _util.formatRecurring)(price.recurring, false, "slash", locale) : ""}`;
226
+ return `${pricing.primary} ${price.recurring ? (0, _util2.formatRecurring)(price.recurring, false, "slash", locale) : ""}`;
123
227
  }
124
228
  return pricing.primary;
125
229
  }, [trialInDays, trialEnd, pricing, item, locale]);
126
- const quantityInventoryError = (0, _util.formatQuantityInventory)(item.price, localQuantityNum, locale);
230
+ const quantityInventoryError = (0, _util2.formatQuantityInventory)(item.price, localQuantityNum, locale);
127
231
  const features = item.price.product?.features || [];
128
232
  return /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
129
233
  direction: "column",
@@ -158,7 +262,7 @@ function ProductItem({
158
262
  alignItems: "center"
159
263
  },
160
264
  children: item.price.type === "recurring" && item.price.recurring ? [pricing.quantity, t("common.billed", {
161
- rule: `${(0, _util.formatRecurring)(item.upsell_price?.recurring || item.price.recurring, true, "per", locale)} ${metered}`
265
+ rule: `${(0, _util2.formatRecurring)(item.upsell_price?.recurring || item.price.recurring, true, "per", locale)} ${metered}`
162
266
  })].filter(Boolean).join(", ") : pricing.quantity
163
267
  })
164
268
  }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
@@ -214,7 +318,7 @@ function ProductItem({
214
318
  sx: {
215
319
  fontSize: "0.75rem"
216
320
  },
217
- children: ["(-", (0, _util.formatAmount)(discountAmount.amount || "0", currency.decimal), " ", currency.symbol, ")"]
321
+ children: ["(-", (0, _util2.formatAmount)(discountAmount.amount || "0", currency.decimal), " ", currency.symbol, ")"]
218
322
  })]
219
323
  }),
220
324
  size: "small",
@@ -375,7 +479,7 @@ function ProductItem({
375
479
  checked: false,
376
480
  onChange: () => onUpsell(item.price_id, item.price.upsell?.upsells_to_id)
377
481
  }), t("payment.checkout.upsell.save", {
378
- recurring: (0, _util.formatRecurring)(item.price.upsell.upsells_to.recurring, true, "per", locale)
482
+ recurring: (0, _util2.formatRecurring)(item.price.upsell.upsells_to.recurring, true, "per", locale)
379
483
  }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_status.default, {
380
484
  label: t("payment.checkout.upsell.off", {
381
485
  saving
@@ -393,7 +497,7 @@ function ProductItem({
393
497
  sx: {
394
498
  fontSize: 12
395
499
  },
396
- children: (0, _util.formatPrice)(item.price.upsell.upsells_to, currency, item.price.product?.unit_label, 1, true, locale)
500
+ children: (0, _util2.formatPrice)(item.price.upsell.upsells_to, currency, item.price.product?.unit_label, 1, true, locale)
397
501
  })]
398
502
  }), canUpsell && item.upsell_price_id && /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
399
503
  direction: "row",
@@ -420,14 +524,14 @@ function ProductItem({
420
524
  checked: true,
421
525
  onChange: () => onDownsell(item.upsell_price_id)
422
526
  }), t("payment.checkout.upsell.revert", {
423
- recurring: t(`common.${(0, _util.formatRecurring)(item.price.recurring)}`)
527
+ recurring: t(`common.${(0, _util2.formatRecurring)(item.price.recurring)}`)
424
528
  })]
425
529
  }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
426
530
  component: "span",
427
531
  sx: {
428
532
  fontSize: 12
429
533
  },
430
- children: (0, _util.formatPrice)(item.price, currency, item.price.product?.unit_label, 1, true, locale)
534
+ children: (0, _util2.formatPrice)(item.price, currency, item.price.product?.unit_label, 1, true, locale)
431
535
  })]
432
536
  })]
433
537
  });
@@ -246,7 +246,7 @@ const Div = (0, _material.styled)("div")`
246
246
  content: '';
247
247
  height: 100px;
248
248
  position: absolute;
249
- background: ${props => props.theme.palette.background.paper};
249
+ background: ${props => props.theme.palette.background.default};
250
250
  transform: rotate(-45deg);
251
251
  }
252
252
  .check-icon .icon-line {
@@ -290,7 +290,7 @@ const Div = (0, _material.styled)("div")`
290
290
  height: 85px;
291
291
  position: absolute;
292
292
  transform: rotate(-45deg);
293
- background-color: ${props => props.theme.palette.background.paper};
293
+ background-color: ${props => props.theme.palette.background.default};
294
294
  }
295
295
 
296
296
  @keyframes rotate-circle {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react",
3
- "version": "1.20.15",
3
+ "version": "1.20.16",
4
4
  "description": "Reusable react components for payment kit v2",
5
5
  "keywords": [
6
6
  "react",
@@ -56,14 +56,14 @@
56
56
  "dependencies": {
57
57
  "@arcblock/did-connect-react": "^3.1.41",
58
58
  "@arcblock/ux": "^3.1.41",
59
- "@arcblock/ws": "^1.25.1",
59
+ "@arcblock/ws": "^1.25.3",
60
60
  "@blocklet/theme": "^3.1.41",
61
61
  "@blocklet/ui-react": "^3.1.41",
62
62
  "@mui/icons-material": "^7.1.2",
63
63
  "@mui/lab": "7.0.0-beta.14",
64
64
  "@mui/material": "^7.1.2",
65
65
  "@mui/system": "^7.1.1",
66
- "@ocap/util": "^1.25.1",
66
+ "@ocap/util": "^1.25.3",
67
67
  "@stripe/react-stripe-js": "^2.9.0",
68
68
  "@stripe/stripe-js": "^2.4.0",
69
69
  "@vitejs/plugin-legacy": "^7.0.0",
@@ -94,7 +94,7 @@
94
94
  "@babel/core": "^7.27.4",
95
95
  "@babel/preset-env": "^7.27.2",
96
96
  "@babel/preset-react": "^7.27.1",
97
- "@blocklet/payment-types": "1.20.15",
97
+ "@blocklet/payment-types": "1.20.16",
98
98
  "@storybook/addon-essentials": "^7.6.20",
99
99
  "@storybook/addon-interactions": "^7.6.20",
100
100
  "@storybook/addon-links": "^7.6.20",
@@ -125,5 +125,5 @@
125
125
  "vite-plugin-babel": "^1.3.1",
126
126
  "vite-plugin-node-polyfills": "^0.23.0"
127
127
  },
128
- "gitHead": "d205c3b1ec7d2b819e375ed2eb8b70c9d48f0bcb"
128
+ "gitHead": "ebf0677dd4414d4abc4129d6663a7e9ddb415281"
129
129
  }
@@ -226,9 +226,24 @@ export default flat({
226
226
  remove: 'Remove from order',
227
227
  },
228
228
  credit: {
229
- oneTimeInfo: 'You will receive {amount} {symbol} credits after payment',
230
- recurringInfo: 'You will receive {amount} {symbol} credits {period}',
231
- expiresIn: 'credits have a validity period of {duration} {unit}',
229
+ normal: {
230
+ oneTime: 'You will receive {amount} {symbol} after payment.',
231
+ oneTimeWithExpiry: 'You will receive {amount} {symbol} after payment, valid for {duration} {unit}.',
232
+ recurring: 'You will receive {amount} {symbol} {period}.',
233
+ recurringWithExpiry: 'You will receive {amount} {symbol} {period}, valid for {duration} {unit}.',
234
+ },
235
+ pending: {
236
+ notEnough:
237
+ 'Your outstanding balance is {amount} {symbol}. To settle it, a minimum purchase of {quantity} units is required.',
238
+ oneTimeEnough:
239
+ 'Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol}, resulting in a net balance of {availableAmount} {symbol} after the deduction.',
240
+ oneTimeEnoughWithExpiry:
241
+ 'Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} (valid for {duration} {unit}), resulting in a net balance of {availableAmount} {symbol} after the deduction.',
242
+ recurringEnough:
243
+ 'Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} {period}, resulting in a net balance of {availableAmount} {symbol} after the deduction.',
244
+ recurringEnoughWithExpiry:
245
+ 'Your outstanding balance is {amount} {symbol}. Upon payment, you will receive {totalAmount} {symbol} {period} (valid for {duration} {unit}), resulting in a net balance of {availableAmount} {symbol} after the deduction.',
246
+ },
232
247
  },
233
248
  expired: {
234
249
  title: 'Expired Link',
@@ -260,9 +260,23 @@ export default flat({
260
260
  },
261
261
  },
262
262
  credit: {
263
- oneTimeInfo: '付款完成后您将获得 {amount} {symbol} 额度',
264
- recurringInfo: '您将{period}获得 {amount} {symbol} 额度',
265
- expiresIn: '额度有效期为 {duration} {unit}',
263
+ normal: {
264
+ oneTime: '付款完成后您将获得 {amount} {symbol} 额度。',
265
+ oneTimeWithExpiry: '付款完成后您将获得 {amount} {symbol} 额度,有效期 {duration} {unit}',
266
+ recurring: '付款完成后,{period}您将获得 {amount} {symbol} 额度。',
267
+ recurringWithExpiry: '付款完成后,{period}您将获得 {amount} {symbol} 额度,有效期 {duration} {unit}。',
268
+ },
269
+ pending: {
270
+ notEnough: '您当前欠费 {amount} {symbol},至少需要购买 {quantity} 数量。',
271
+ oneTimeEnough:
272
+ '您当前欠费 {amount} {symbol},付款后将获得 {totalAmount} {symbol} 额度,扣除欠费后净余额为 {availableAmount} {symbol}。',
273
+ oneTimeEnoughWithExpiry:
274
+ '您当前欠费 {amount} {symbol},付款后将获得 {totalAmount} {symbol} 额度(有效期 {duration} {unit}),扣除欠费后净余额为 {availableAmount} {symbol}。',
275
+ recurringEnough:
276
+ '您当前欠费 {amount} {symbol},付款后{period}将获得 {totalAmount} {symbol} 额度,扣除欠费后净余额为 {availableAmount} {symbol}。',
277
+ recurringEnoughWithExpiry:
278
+ '您当前欠费 {amount} {symbol},付款后{period}将获得 {totalAmount} {symbol} 额度(有效期 {duration} {unit}),扣除欠费后净余额为 {availableAmount} {symbol}。',
279
+ },
266
280
  },
267
281
  emptyItems: {
268
282
  title: '没有任何购买项目',
@@ -3,7 +3,9 @@ import type { PriceRecurring, TLineItemExpanded, TPaymentCurrency } from '@block
3
3
  import { Box, Stack, Typography, IconButton, TextField, Alert, Chip } from '@mui/material';
4
4
  import { Add, Remove, LocalOffer } from '@mui/icons-material';
5
5
 
6
- import React, { useMemo, useState } from 'react';
6
+ import React, { useEffect, useMemo, useState } from 'react';
7
+ import { useRequest } from 'ahooks';
8
+ import { BN, fromTokenToUnit } from '@ocap/util';
7
9
  import Status from '../components/status';
8
10
  import Switch from '../components/switch-button';
9
11
  import {
@@ -15,6 +17,7 @@ import {
15
17
  formatRecurring,
16
18
  formatUpsellSaving,
17
19
  formatAmount,
20
+ formatBNStr,
18
21
  } from '../libs/util';
19
22
  import ProductCard from './product-card';
20
23
  import dayjs from '../libs/dayjs';
@@ -41,6 +44,35 @@ type Props = {
41
44
  showFeatures?: boolean;
42
45
  };
43
46
 
47
+ const getRecommendedQuantityFromUrl = (priceId: string): number | undefined => {
48
+ try {
49
+ const urlParams = new URLSearchParams(window.location.search);
50
+ const recommendedQuantity = urlParams.get(`qty_${priceId}`) || urlParams.get('qty');
51
+ return recommendedQuantity ? Math.max(1, parseInt(recommendedQuantity, 10)) : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ };
56
+
57
+ const getUserQuantityPreference = (userDid: string, priceId: string): number | undefined => {
58
+ try {
59
+ const key = `quantity_preference_${userDid}_${priceId}`;
60
+ const stored = localStorage.getItem(key);
61
+ return stored ? Math.max(1, parseInt(stored, 10)) : undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ };
66
+
67
+ const saveUserQuantityPreference = (userDid: string, priceId: string, quantity: number): void => {
68
+ try {
69
+ const key = `quantity_preference_${userDid}_${priceId}`;
70
+ localStorage.setItem(key, quantity.toString());
71
+ } catch {
72
+ // Silently fail if localStorage is not available
73
+ }
74
+ };
75
+
44
76
  export default function ProductItem({
45
77
  item,
46
78
  items,
@@ -57,7 +89,7 @@ export default function ProductItem({
57
89
  showFeatures = false,
58
90
  }: Props) {
59
91
  const { t, locale } = useLocaleContext();
60
- const { settings, setPayable } = usePaymentContext();
92
+ const { settings, setPayable, session, api } = usePaymentContext();
61
93
  const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays }, locale);
62
94
  const saving = formatUpsellSaving(items, currency);
63
95
  const metered = item.price?.recurring?.usage_type === 'metered' ? t('common.metered') : '';
@@ -71,7 +103,47 @@ export default function ProductItem({
71
103
  const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
72
104
  const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || 'days';
73
105
 
74
- const [localQuantity, setLocalQuantity] = useState<number | undefined>(item.quantity);
106
+ const userDid = session?.user?.did;
107
+
108
+ const { data: pendingAmount } = useRequest(
109
+ async () => {
110
+ if (!isCreditProduct || !userDid) return null;
111
+ try {
112
+ const { data } = await api.get('/api/meter-events/pending-amount', {
113
+ params: {
114
+ customer_id: userDid,
115
+ currency_id: creditCurrency?.id,
116
+ },
117
+ });
118
+ return data?.[creditCurrency?.id || ''];
119
+ } catch (error) {
120
+ console.warn('Failed to fetch pending amount:', error);
121
+ return null;
122
+ }
123
+ },
124
+ {
125
+ refreshDeps: [isCreditProduct, userDid, creditCurrency?.id],
126
+ }
127
+ );
128
+
129
+ // calculate initial quantity: priority URL recommendation > user preference > item.quantity
130
+ const getInitialQuantity = (): number | undefined => {
131
+ const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
132
+ if (urlQuantity && urlQuantity > 0) {
133
+ return urlQuantity;
134
+ }
135
+
136
+ if (userDid) {
137
+ const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
138
+ if (preferredQuantity && preferredQuantity > 0) {
139
+ return preferredQuantity;
140
+ }
141
+ }
142
+
143
+ return item.quantity;
144
+ };
145
+
146
+ const [localQuantity, setLocalQuantity] = useState<number | undefined>(getInitialQuantity());
75
147
  const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal';
76
148
  const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
77
149
  const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
@@ -80,6 +152,15 @@ export default function ProductItem({
80
152
  : adjustableQuantity.maximum || Infinity;
81
153
  const localQuantityNum = localQuantity || 0;
82
154
 
155
+ useEffect(() => {
156
+ const initialQuantity = getInitialQuantity();
157
+ if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
158
+ // need update checkout session
159
+ onQuantityChange(item.price_id, initialQuantity);
160
+ }
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, []);
163
+
83
164
  const handleQuantityChange = (newQuantity: number) => {
84
165
  if (!newQuantity) {
85
166
  setLocalQuantity(undefined);
@@ -93,6 +174,10 @@ export default function ProductItem({
93
174
  }
94
175
  setLocalQuantity(newQuantity);
95
176
  onQuantityChange(item.price_id, newQuantity);
177
+
178
+ if (userDid && newQuantity > 0) {
179
+ saveUserQuantityPreference(userDid, item.price.id, newQuantity);
180
+ }
96
181
  }
97
182
  };
98
183
 
@@ -119,30 +204,66 @@ export default function ProductItem({
119
204
  const formatCreditInfo = () => {
120
205
  if (!isCreditProduct) return null;
121
206
 
207
+ const totalCreditStr = formatNumber(creditAmount * (localQuantity || 0));
208
+ const currencySymbol = creditCurrency?.symbol || 'Credits';
209
+ const hasPendingAmount = pendingAmount && new BN(pendingAmount || '0').gt(new BN(0));
122
210
  const isRecurring = item.price.type === 'recurring';
123
- const totalCredit = formatNumber(creditAmount * (localQuantity || 0));
211
+ const hasExpiry = validDuration && validDuration > 0;
124
212
 
125
- let message = '';
126
- if (isRecurring) {
127
- message = t('payment.checkout.credit.recurringInfo', {
128
- amount: totalCredit,
213
+ const buildBaseParams = () => ({
214
+ amount: totalCreditStr,
215
+ symbol: currencySymbol,
216
+ ...(hasExpiry && {
217
+ duration: validDuration,
218
+ unit: t(`common.${validDurationUnit}`),
219
+ }),
220
+ ...(isRecurring && {
129
221
  period: formatRecurring(item.price.recurring!, true, 'per', locale),
130
- });
131
- } else {
132
- message = t('payment.checkout.credit.oneTimeInfo', {
133
- amount: totalCredit,
134
- symbol: creditCurrency?.symbol || 'Credits',
135
- });
136
- }
222
+ }),
223
+ });
137
224
 
138
- if (validDuration && validDuration > 0) {
139
- message += `,${t('payment.checkout.credit.expiresIn', {
225
+ const buildPendingParams = (pendingBN: BN, availableAmount?: string) => ({
226
+ amount: formatBNStr(pendingBN.toString(), creditCurrency?.decimal || 2),
227
+ symbol: currencySymbol,
228
+ totalAmount: totalCreditStr,
229
+ ...(availableAmount && {
230
+ availableAmount: formatBNStr(availableAmount, creditCurrency?.decimal || 2),
231
+ }),
232
+ ...(hasExpiry && {
140
233
  duration: validDuration,
141
234
  unit: t(`common.${validDurationUnit}`),
142
- })}`;
235
+ }),
236
+ ...(isRecurring && {
237
+ period: formatRecurring(item.price.recurring!, true, 'per', locale),
238
+ }),
239
+ });
240
+
241
+ const getLocaleKey = (category: 'normal' | 'pending', type: string) => {
242
+ const suffix = hasExpiry ? 'WithExpiry' : '';
243
+ return `payment.checkout.credit.${category}.${type}${suffix}`;
244
+ };
245
+
246
+ if (!hasPendingAmount) {
247
+ const type = isRecurring ? 'recurring' : 'oneTime';
248
+ return t(getLocaleKey('normal', type), buildBaseParams());
249
+ }
250
+
251
+ const pendingAmountBN = new BN(pendingAmount || '0');
252
+ const creditAmountBN = fromTokenToUnit(new BN(creditAmount), creditCurrency?.decimal || 2);
253
+ const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
254
+ const currentPurchaseCreditBN = creditAmountBN.mul(new BN(localQuantity || 0));
255
+ const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
256
+
257
+ if (!new BN(actualAvailable).gt(new BN(0))) {
258
+ return t('payment.checkout.credit.pending.notEnough', {
259
+ amount: formatBNStr(pendingAmountBN.toString(), creditCurrency?.decimal || 2),
260
+ symbol: currencySymbol,
261
+ quantity: formatNumber(minQuantityNeeded),
262
+ });
143
263
  }
144
264
 
145
- return message;
265
+ const type = isRecurring ? 'recurringEnough' : 'oneTimeEnough';
266
+ return t(getLocaleKey('pending', type), buildPendingParams(pendingAmountBN, actualAvailable));
146
267
  };
147
268
 
148
269
  const primaryText = useMemo(() => {
@@ -271,7 +271,7 @@ const Div = styled('div')`
271
271
  content: '';
272
272
  height: 100px;
273
273
  position: absolute;
274
- background: ${(props) => props.theme.palette.background.paper};
274
+ background: ${(props) => props.theme.palette.background.default};
275
275
  transform: rotate(-45deg);
276
276
  }
277
277
  .check-icon .icon-line {
@@ -315,7 +315,7 @@ const Div = styled('div')`
315
315
  height: 85px;
316
316
  position: absolute;
317
317
  transform: rotate(-45deg);
318
- background-color: ${(props) => props.theme.palette.background.paper};
318
+ background-color: ${(props) => props.theme.palette.background.default};
319
319
  }
320
320
 
321
321
  @keyframes rotate-circle {