@blocklet/payment-react 1.23.10 → 1.24.0

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
3
- import { Box, Typography, Grid, Stack, Link, Button } from "@mui/material";
3
+ import { Box, Typography, Grid, Stack, Link, Button, Chip } from "@mui/material";
4
4
  import { useRequest } from "ahooks";
5
5
  import { useNavigate } from "react-router-dom";
6
6
  import React, { useCallback, useEffect, useRef, useState } from "react";
@@ -130,6 +130,7 @@ const TransactionsTable = React.memo((props) => {
130
130
  customBodyRenderLite: (_, index) => {
131
131
  const item = data?.list[index];
132
132
  const isGrant = item.activity_type === "grant";
133
+ const isExpiredGrant = isGrant && item.status === "expired";
133
134
  const amount = isGrant ? item.amount : item.credit_amount;
134
135
  const currency = item.paymentCurrency || item.currency;
135
136
  const unit = !isGrant && item.meter?.unit ? item.meter.unit : currency?.symbol;
@@ -137,11 +138,11 @@ const TransactionsTable = React.memo((props) => {
137
138
  if (!includeGrants) {
138
139
  return /* @__PURE__ */ jsx(Box, { onClick: (e) => handleTransactionClick(e, item), children: /* @__PURE__ */ jsx(Typography, { children: displayAmount }) });
139
140
  }
140
- return /* @__PURE__ */ jsx(Box, { onClick: (e) => handleTransactionClick(e, item), children: /* @__PURE__ */ jsxs(
141
+ const amountNode = /* @__PURE__ */ jsxs(
141
142
  Typography,
142
143
  {
143
144
  sx: {
144
- color: isGrant ? "success.main" : "error.main"
145
+ color: isGrant ? isExpiredGrant ? "text.disabled" : "success.main" : "error.main"
145
146
  },
146
147
  children: [
147
148
  isGrant ? "+" : "-",
@@ -149,7 +150,25 @@ const TransactionsTable = React.memo((props) => {
149
150
  displayAmount
150
151
  ]
151
152
  }
152
- ) });
153
+ );
154
+ return /* @__PURE__ */ jsx(Box, { onClick: (e) => handleTransactionClick(e, item), children: /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", justifyContent: "flex-end", sx: { width: "100%" }, children: [
155
+ isExpiredGrant && /* @__PURE__ */ jsx(
156
+ Chip,
157
+ {
158
+ label: t("admin.creditGrants.status.expired"),
159
+ size: "small",
160
+ variant: "outlined",
161
+ sx: {
162
+ mr: 2,
163
+ height: 18,
164
+ fontSize: "12px",
165
+ color: "text.disabled",
166
+ borderColor: "text.disabled"
167
+ }
168
+ }
169
+ ),
170
+ amountNode
171
+ ] }) });
153
172
  }
154
173
  }
155
174
  },
@@ -160,8 +179,10 @@ const TransactionsTable = React.memo((props) => {
160
179
  customBodyRenderLite: (_, index) => {
161
180
  const item = data?.list[index];
162
181
  const isGrant = item.activity_type === "grant";
182
+ const isExpiredGrant = isGrant && item.status === "expired";
163
183
  const grantName = isGrant ? item.name : item.creditGrant.name;
164
184
  const grantId = isGrant ? item.id : item.credit_grant_id;
185
+ const nameNode = /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { cursor: "pointer", color: isExpiredGrant ? "text.disabled" : void 0 }, children: grantName || `Grant ${grantId.slice(-6)}` });
165
186
  return /* @__PURE__ */ jsx(
166
187
  Stack,
167
188
  {
@@ -174,7 +195,7 @@ const TransactionsTable = React.memo((props) => {
174
195
  sx: {
175
196
  alignItems: "center"
176
197
  },
177
- children: /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { cursor: "pointer" }, children: grantName || `Grant ${grantId.slice(-6)}` })
198
+ children: nameNode
178
199
  }
179
200
  );
180
201
  }
@@ -187,8 +208,10 @@ const TransactionsTable = React.memo((props) => {
187
208
  customBodyRenderLite: (_, index) => {
188
209
  const item = data?.list[index];
189
210
  const isGrant = item.activity_type === "grant";
211
+ const isExpiredGrant = isGrant && item.status === "expired";
190
212
  const description = isGrant ? item.name || item.description || "Credit Granted" : item.subscription?.description || item.description || `${item.meter_event_name} usage`;
191
- return /* @__PURE__ */ jsx(Box, { onClick: (e) => handleTransactionClick(e, item), children: /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { fontWeight: 400 }, children: description }) });
213
+ const descriptionNode = /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { fontWeight: 400, color: isExpiredGrant ? "text.disabled" : void 0 }, children: description });
214
+ return /* @__PURE__ */ jsx(Box, { onClick: (e) => handleTransactionClick(e, item), children: descriptionNode });
192
215
  }
193
216
  }
194
217
  },
package/es/locales/en.js CHANGED
@@ -264,6 +264,10 @@ export default flat({
264
264
  oneTimeEnoughWithExpiry: "You have a usage overage of {amount}. This purchase adds {totalAmount} (valid for {duration} {unit}). After covering the overage, your new available balance will be {availableAmount}.",
265
265
  recurringEnough: "You have a usage overage of {amount}. This subscription adds {totalAmount} {period}. After covering the overage, your new available balance will be {availableAmount}.",
266
266
  recurringEnoughWithExpiry: "You have a usage overage of {amount}. This subscription adds {totalAmount} {period} (valid for {duration} {unit}). After covering the overage, your new available balance will be {availableAmount}."
267
+ },
268
+ schedule: {
269
+ periodic: "Grant {amount} every {interval}.",
270
+ withRefresh: "Grant {amount} every {interval}; unused credits expire with the next grant."
267
271
  }
268
272
  },
269
273
  expired: {
package/es/locales/zh.js CHANGED
@@ -301,6 +301,10 @@ export default flat({
301
301
  oneTimeEnoughWithExpiry: "\u60A8\u6709 {amount} \u7684\u4F7F\u7528\u8D85\u989D\u3002\u672C\u6B21\u8D2D\u4E70\u5C06\u589E\u52A0 {totalAmount}\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\u3002\u6263\u9664\u8D85\u989D\u540E\uFF0C\u60A8\u7684\u65B0\u53EF\u7528\u4F59\u989D\u5C06\u4E3A {availableAmount}\u3002",
302
302
  recurringEnough: "\u60A8\u6709 {amount} \u7684\u4F7F\u7528\u8D85\u989D\u3002\u672C\u8BA2\u9605{period}\u5C06\u589E\u52A0 {totalAmount}\u3002\u6263\u9664\u8D85\u989D\u540E\uFF0C\u60A8\u7684\u65B0\u53EF\u7528\u4F59\u989D\u5C06\u4E3A {availableAmount}\u3002",
303
303
  recurringEnoughWithExpiry: "\u60A8\u6709 {amount} \u7684\u4F7F\u7528\u8D85\u989D\u3002\u672C\u8BA2\u9605{period}\u5C06\u589E\u52A0 {totalAmount}\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\u3002\u6263\u9664\u8D85\u989D\u540E\uFF0C\u60A8\u7684\u65B0\u53EF\u7528\u4F59\u989D\u5C06\u4E3A {availableAmount}\u3002"
304
+ },
305
+ schedule: {
306
+ periodic: "\u6BCF{interval}\u53D1\u653E {amount}\u3002",
307
+ withRefresh: "\u6BCF{interval}\u53D1\u653E {amount}\uFF0C\u672A\u4F7F\u7528\u989D\u5EA6\u5C06\u5728\u4E0B\u6B21\u53D1\u653E\u65F6\u8FC7\u671F\u3002"
304
308
  }
305
309
  },
306
310
  emptyItems: {
@@ -17,6 +17,7 @@ import {
17
17
  formatUpsellSaving,
18
18
  formatAmount,
19
19
  formatCreditForCheckout,
20
+ formatCreditAmount,
20
21
  formatBNStr
21
22
  } from "../libs/util.js";
22
23
  import ProductCard from "./product-card.js";
@@ -69,11 +70,13 @@ export default function ProductItem({
69
70
  const saving = formatUpsellSaving(items, currency);
70
71
  const metered = item.price?.recurring?.usage_type === "metered" ? t("common.metered") : "";
71
72
  const canUpsell = mode === "normal" && items.length === 1;
72
- const isCreditProduct = item.price.product?.type === "credit" && item.price.metadata?.credit_config?.credit_amount;
73
- const creditAmount = isCreditProduct ? Number(item.price.metadata.credit_config.credit_amount) : 0;
74
- const creditCurrency = isCreditProduct ? findCurrency(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? "") : null;
73
+ const isCreditProduct = item.price.product?.type === "credit" && item.price.metadata?.credit_config;
74
+ const creditAmount = isCreditProduct && item.price.metadata?.credit_config?.credit_amount ? Number(item.price.metadata.credit_config.credit_amount) : 0;
75
+ const creditCurrency = isCreditProduct && item.price.metadata?.credit_config?.currency_id ? findCurrency(settings.paymentMethods, item.price.metadata.credit_config.currency_id) : null;
75
76
  const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
76
77
  const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || "days";
78
+ const scheduleConfig = item.price.metadata?.credit_config?.schedule;
79
+ const hasSchedule = scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== "invoice";
77
80
  const userDid = session?.user?.did;
78
81
  const { data: pendingAmount } = useRequest(
79
82
  async () => {
@@ -95,42 +98,76 @@ export default function ProductItem({
95
98
  refreshDeps: [isCreditProduct, userDid, creditCurrency?.id]
96
99
  }
97
100
  );
98
- const getInitialQuantity = () => {
101
+ const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
102
+ const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
103
+ const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
104
+ const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
105
+ const getMinQuantityForPending = useMemo(() => {
106
+ if (!isCreditProduct || !pendingAmount) return null;
107
+ const pendingAmountBN = new BN(pendingAmount || "0");
108
+ if (!pendingAmountBN.gt(new BN(0))) return null;
109
+ const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2);
110
+ return Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
111
+ }, [isCreditProduct, pendingAmount, creditAmount, creditCurrency?.decimal]);
112
+ const initialQuantity = useMemo(() => {
99
113
  const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
100
114
  if (urlQuantity && urlQuantity > 0) {
115
+ if (canAdjustQuantity && getMinQuantityForPending) {
116
+ return Math.max(urlQuantity, getMinQuantityForPending, minQuantity);
117
+ }
101
118
  return urlQuantity;
102
119
  }
103
120
  if (userDid) {
104
121
  const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
105
122
  if (preferredQuantity && preferredQuantity > 0) {
123
+ if (canAdjustQuantity && getMinQuantityForPending) {
124
+ return Math.max(preferredQuantity, getMinQuantityForPending, minQuantity);
125
+ }
106
126
  return preferredQuantity;
107
127
  }
108
128
  }
109
- return item.quantity;
110
- };
111
- const [localQuantity, setLocalQuantity] = useState(getInitialQuantity());
112
- const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
113
- const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
114
- const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
115
- const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
129
+ let baseQuantity = item.quantity;
130
+ if (canAdjustQuantity && getMinQuantityForPending) {
131
+ baseQuantity = Math.max(baseQuantity, getMinQuantityForPending, minQuantity);
132
+ }
133
+ return baseQuantity;
134
+ }, [item.quantity, item.price.id, userDid, getMinQuantityForPending, canAdjustQuantity, minQuantity]);
135
+ const [localQuantity, setLocalQuantity] = useState(initialQuantity);
116
136
  const localQuantityNum = localQuantity || 0;
117
137
  useEffect(() => {
118
- const initialQuantity = getInitialQuantity();
119
- if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
120
- onQuantityChange(item.price_id, initialQuantity);
138
+ if (initialQuantity && initialQuantity > 0) {
139
+ if (initialQuantity !== localQuantity) {
140
+ setLocalQuantity(initialQuantity);
141
+ }
142
+ if (initialQuantity !== item.quantity) {
143
+ onQuantityChange(item.price_id, initialQuantity);
144
+ }
145
+ if (isCreditProduct && pendingAmount && getMinQuantityForPending) {
146
+ setPayable(initialQuantity >= getMinQuantityForPending);
147
+ } else {
148
+ setPayable(true);
149
+ }
121
150
  }
122
- }, []);
151
+ }, [initialQuantity, isCreditProduct, pendingAmount, getMinQuantityForPending]);
123
152
  const handleQuantityChange = (newQuantity) => {
124
153
  if (!newQuantity) {
125
154
  setLocalQuantity(void 0);
126
155
  setPayable(false);
127
156
  return;
128
157
  }
129
- setPayable(true);
130
158
  if (newQuantity >= minQuantity && newQuantity <= maxQuantity) {
131
159
  if (formatQuantityInventory(item.price, newQuantity, locale)) {
132
160
  return;
133
161
  }
162
+ if (isCreditProduct && pendingAmount && getMinQuantityForPending) {
163
+ if (newQuantity < getMinQuantityForPending) {
164
+ setPayable(false);
165
+ setLocalQuantity(newQuantity);
166
+ onQuantityChange(item.price_id, newQuantity);
167
+ return;
168
+ }
169
+ }
170
+ setPayable(true);
134
171
  setLocalQuantity(newQuantity);
135
172
  onQuantityChange(item.price_id, newQuantity);
136
173
  if (userDid && newQuantity > 0) {
@@ -204,7 +241,7 @@ export default function ProductItem({
204
241
  }
205
242
  const pendingAmountBN = new BN(pendingAmount || "0");
206
243
  const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2);
207
- const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
244
+ const minQuantityNeeded = getMinQuantityForPending || 0;
208
245
  const currentPurchaseCreditBN = creditAmountBN.mul(new BN(localQuantity || 0));
209
246
  const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
210
247
  if (!new BN(actualAvailable).gt(new BN(0))) {
@@ -220,6 +257,44 @@ export default function ProductItem({
220
257
  const type = isRecurring ? "recurringEnough" : "oneTimeEnough";
221
258
  return t(getLocaleKey("pending", type), buildPendingParams(pendingAmountBN, actualAvailable));
222
259
  };
260
+ const formatScheduleInfo = () => {
261
+ if (!hasSchedule || !scheduleConfig) return null;
262
+ const totalCredit = creditAmount * (localQuantity || 0);
263
+ const currencySymbol = creditCurrency?.symbol || "Credits";
264
+ const intervalUnit = scheduleConfig.interval_unit;
265
+ const intervalValue = scheduleConfig.interval_value;
266
+ let amountPerGrant;
267
+ if (scheduleConfig.amount_per_grant) {
268
+ amountPerGrant = Number(scheduleConfig.amount_per_grant) * (localQuantity || 1);
269
+ } else {
270
+ const billingIntervalUnit = item.price.recurring?.interval || "month";
271
+ const billingIntervalCount = item.price.recurring?.interval_count || 1;
272
+ const unitToHours = {
273
+ hour: 1,
274
+ day: 24,
275
+ week: 168,
276
+ month: 720
277
+ // ~30 days
278
+ };
279
+ const billingHours = unitToHours[billingIntervalUnit] * billingIntervalCount;
280
+ const scheduleHours = unitToHours[intervalUnit] * intervalValue;
281
+ const grantsPerPeriod = Math.floor(billingHours / scheduleHours);
282
+ amountPerGrant = grantsPerPeriod > 0 ? totalCredit / grantsPerPeriod : totalCredit;
283
+ }
284
+ const formattedAmount = formatCreditAmount(formatNumber(amountPerGrant), currencySymbol);
285
+ const intervalDisplay = intervalValue === 1 ? t(`common.${intervalUnit}`) : ` ${intervalValue} ${t(`common.${intervalUnit}s`)} `;
286
+ const expireWithNext = scheduleConfig.expire_with_next_grant;
287
+ if (expireWithNext) {
288
+ return t("payment.checkout.credit.schedule.withRefresh", {
289
+ amount: formattedAmount,
290
+ interval: intervalDisplay
291
+ });
292
+ }
293
+ return t("payment.checkout.credit.schedule.periodic", {
294
+ amount: formattedAmount,
295
+ interval: intervalDisplay
296
+ });
297
+ };
223
298
  const primaryText = useMemo(() => {
224
299
  const price = item.upsell_price || item.price || {};
225
300
  const isRecurring = price?.type === "recurring" && price?.recurring;
@@ -461,7 +536,7 @@ export default function ProductItem({
461
536
  ]
462
537
  }
463
538
  ) }),
464
- isCreditProduct && /* @__PURE__ */ jsx(Alert, { severity: "info", sx: { mt: 1, fontSize: "0.875rem" }, icon: false, children: formatCreditInfo() }),
539
+ isCreditProduct && /* @__PURE__ */ jsx(Alert, { severity: "info", sx: { mt: 1, fontSize: "0.875rem" }, icon: false, children: hasSchedule ? /* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: "inherit" }, children: formatScheduleInfo() }) : /* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: "inherit" }, children: formatCreditInfo() }) }),
465
540
  children
466
541
  ]
467
542
  }
@@ -154,6 +154,7 @@ const TransactionsTable = _react.default.memo(props => {
154
154
  customBodyRenderLite: (_, index) => {
155
155
  const item = data?.list[index];
156
156
  const isGrant = item.activity_type === "grant";
157
+ const isExpiredGrant = isGrant && item.status === "expired";
157
158
  const amount = isGrant ? item.amount : item.credit_amount;
158
159
  const currency = item.paymentCurrency || item.currency;
159
160
  const unit = !isGrant && item.meter?.unit ? item.meter.unit : currency?.symbol;
@@ -166,13 +167,34 @@ const TransactionsTable = _react.default.memo(props => {
166
167
  })
167
168
  });
168
169
  }
170
+ const amountNode = /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
171
+ sx: {
172
+ color: isGrant ? isExpiredGrant ? "text.disabled" : "success.main" : "error.main"
173
+ },
174
+ children: [isGrant ? "+" : "-", " ", displayAmount]
175
+ });
169
176
  return /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
170
177
  onClick: e => handleTransactionClick(e, item),
171
- children: /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
178
+ children: /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
179
+ direction: "row",
180
+ spacing: 1,
181
+ alignItems: "center",
182
+ justifyContent: "flex-end",
172
183
  sx: {
173
- color: isGrant ? "success.main" : "error.main"
184
+ width: "100%"
174
185
  },
175
- children: [isGrant ? "+" : "-", " ", displayAmount]
186
+ children: [isExpiredGrant && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Chip, {
187
+ label: t("admin.creditGrants.status.expired"),
188
+ size: "small",
189
+ variant: "outlined",
190
+ sx: {
191
+ mr: 2,
192
+ height: 18,
193
+ fontSize: "12px",
194
+ color: "text.disabled",
195
+ borderColor: "text.disabled"
196
+ }
197
+ }), amountNode]
176
198
  })
177
199
  });
178
200
  }
@@ -184,8 +206,17 @@ const TransactionsTable = _react.default.memo(props => {
184
206
  customBodyRenderLite: (_, index) => {
185
207
  const item = data?.list[index];
186
208
  const isGrant = item.activity_type === "grant";
209
+ const isExpiredGrant = isGrant && item.status === "expired";
187
210
  const grantName = isGrant ? item.name : item.creditGrant.name;
188
211
  const grantId = isGrant ? item.id : item.credit_grant_id;
212
+ const nameNode = /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
213
+ variant: "body2",
214
+ sx: {
215
+ cursor: "pointer",
216
+ color: isExpiredGrant ? "text.disabled" : void 0
217
+ },
218
+ children: grantName || `Grant ${grantId.slice(-6)}`
219
+ });
189
220
  return /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Stack, {
190
221
  direction: "row",
191
222
  spacing: 1,
@@ -196,13 +227,7 @@ const TransactionsTable = _react.default.memo(props => {
196
227
  sx: {
197
228
  alignItems: "center"
198
229
  },
199
- children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
200
- variant: "body2",
201
- sx: {
202
- cursor: "pointer"
203
- },
204
- children: grantName || `Grant ${grantId.slice(-6)}`
205
- })
230
+ children: nameNode
206
231
  });
207
232
  }
208
233
  }
@@ -213,16 +238,19 @@ const TransactionsTable = _react.default.memo(props => {
213
238
  customBodyRenderLite: (_, index) => {
214
239
  const item = data?.list[index];
215
240
  const isGrant = item.activity_type === "grant";
241
+ const isExpiredGrant = isGrant && item.status === "expired";
216
242
  const description = isGrant ? item.name || item.description || "Credit Granted" : item.subscription?.description || item.description || `${item.meter_event_name} usage`;
243
+ const descriptionNode = /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
244
+ variant: "body2",
245
+ sx: {
246
+ fontWeight: 400,
247
+ color: isExpiredGrant ? "text.disabled" : void 0
248
+ },
249
+ children: description
250
+ });
217
251
  return /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
218
252
  onClick: e => handleTransactionClick(e, item),
219
- children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
220
- variant: "body2",
221
- sx: {
222
- fontWeight: 400
223
- },
224
- children: description
225
- })
253
+ children: descriptionNode
226
254
  });
227
255
  }
228
256
  }
package/lib/locales/en.js CHANGED
@@ -271,6 +271,10 @@ module.exports = (0, _flat.default)({
271
271
  oneTimeEnoughWithExpiry: "You have a usage overage of {amount}. This purchase adds {totalAmount} (valid for {duration} {unit}). After covering the overage, your new available balance will be {availableAmount}.",
272
272
  recurringEnough: "You have a usage overage of {amount}. This subscription adds {totalAmount} {period}. After covering the overage, your new available balance will be {availableAmount}.",
273
273
  recurringEnoughWithExpiry: "You have a usage overage of {amount}. This subscription adds {totalAmount} {period} (valid for {duration} {unit}). After covering the overage, your new available balance will be {availableAmount}."
274
+ },
275
+ schedule: {
276
+ periodic: "Grant {amount} every {interval}.",
277
+ withRefresh: "Grant {amount} every {interval}; unused credits expire with the next grant."
274
278
  }
275
279
  },
276
280
  expired: {
package/lib/locales/zh.js CHANGED
@@ -308,6 +308,10 @@ module.exports = (0, _flat.default)({
308
308
  oneTimeEnoughWithExpiry: "\u60A8\u6709 {amount} \u7684\u4F7F\u7528\u8D85\u989D\u3002\u672C\u6B21\u8D2D\u4E70\u5C06\u589E\u52A0 {totalAmount}\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\u3002\u6263\u9664\u8D85\u989D\u540E\uFF0C\u60A8\u7684\u65B0\u53EF\u7528\u4F59\u989D\u5C06\u4E3A {availableAmount}\u3002",
309
309
  recurringEnough: "\u60A8\u6709 {amount} \u7684\u4F7F\u7528\u8D85\u989D\u3002\u672C\u8BA2\u9605{period}\u5C06\u589E\u52A0 {totalAmount}\u3002\u6263\u9664\u8D85\u989D\u540E\uFF0C\u60A8\u7684\u65B0\u53EF\u7528\u4F59\u989D\u5C06\u4E3A {availableAmount}\u3002",
310
310
  recurringEnoughWithExpiry: "\u60A8\u6709 {amount} \u7684\u4F7F\u7528\u8D85\u989D\u3002\u672C\u8BA2\u9605{period}\u5C06\u589E\u52A0 {totalAmount}\uFF08\u6709\u6548\u671F {duration} {unit}\uFF09\u3002\u6263\u9664\u8D85\u989D\u540E\uFF0C\u60A8\u7684\u65B0\u53EF\u7528\u4F59\u989D\u5C06\u4E3A {availableAmount}\u3002"
311
+ },
312
+ schedule: {
313
+ periodic: "\u6BCF{interval}\u53D1\u653E {amount}\u3002",
314
+ withRefresh: "\u6BCF{interval}\u53D1\u653E {amount}\uFF0C\u672A\u4F7F\u7528\u989D\u5EA6\u5C06\u5728\u4E0B\u6B21\u53D1\u653E\u65F6\u8FC7\u671F\u3002"
311
315
  }
312
316
  },
313
317
  emptyItems: {
@@ -76,11 +76,13 @@ function ProductItem({
76
76
  const saving = (0, _util2.formatUpsellSaving)(items, currency);
77
77
  const metered = item.price?.recurring?.usage_type === "metered" ? t("common.metered") : "";
78
78
  const canUpsell = mode === "normal" && items.length === 1;
79
- const isCreditProduct = item.price.product?.type === "credit" && item.price.metadata?.credit_config?.credit_amount;
80
- const creditAmount = isCreditProduct ? Number(item.price.metadata.credit_config.credit_amount) : 0;
81
- const creditCurrency = isCreditProduct ? (0, _util2.findCurrency)(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? "") : null;
79
+ const isCreditProduct = item.price.product?.type === "credit" && item.price.metadata?.credit_config;
80
+ const creditAmount = isCreditProduct && item.price.metadata?.credit_config?.credit_amount ? Number(item.price.metadata.credit_config.credit_amount) : 0;
81
+ const creditCurrency = isCreditProduct && item.price.metadata?.credit_config?.currency_id ? (0, _util2.findCurrency)(settings.paymentMethods, item.price.metadata.credit_config.currency_id) : null;
82
82
  const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
83
83
  const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || "days";
84
+ const scheduleConfig = item.price.metadata?.credit_config?.schedule;
85
+ const hasSchedule = scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== "invoice";
84
86
  const userDid = session?.user?.did;
85
87
  const {
86
88
  data: pendingAmount
@@ -103,42 +105,76 @@ function ProductItem({
103
105
  }, {
104
106
  refreshDeps: [isCreditProduct, userDid, creditCurrency?.id]
105
107
  });
106
- const getInitialQuantity = () => {
108
+ const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
109
+ const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
110
+ const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
111
+ const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
112
+ const getMinQuantityForPending = (0, _react.useMemo)(() => {
113
+ if (!isCreditProduct || !pendingAmount) return null;
114
+ const pendingAmountBN = new _util.BN(pendingAmount || "0");
115
+ if (!pendingAmountBN.gt(new _util.BN(0))) return null;
116
+ const creditAmountBN = (0, _util.fromTokenToUnit)(creditAmount, creditCurrency?.decimal || 2);
117
+ return Math.ceil(pendingAmountBN.mul(new _util.BN(100)).div(creditAmountBN).toNumber() / 100);
118
+ }, [isCreditProduct, pendingAmount, creditAmount, creditCurrency?.decimal]);
119
+ const initialQuantity = (0, _react.useMemo)(() => {
107
120
  const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
108
121
  if (urlQuantity && urlQuantity > 0) {
122
+ if (canAdjustQuantity && getMinQuantityForPending) {
123
+ return Math.max(urlQuantity, getMinQuantityForPending, minQuantity);
124
+ }
109
125
  return urlQuantity;
110
126
  }
111
127
  if (userDid) {
112
128
  const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
113
129
  if (preferredQuantity && preferredQuantity > 0) {
130
+ if (canAdjustQuantity && getMinQuantityForPending) {
131
+ return Math.max(preferredQuantity, getMinQuantityForPending, minQuantity);
132
+ }
114
133
  return preferredQuantity;
115
134
  }
116
135
  }
117
- return item.quantity;
118
- };
119
- const [localQuantity, setLocalQuantity] = (0, _react.useState)(getInitialQuantity());
120
- const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
121
- const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
122
- const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
123
- const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
136
+ let baseQuantity = item.quantity;
137
+ if (canAdjustQuantity && getMinQuantityForPending) {
138
+ baseQuantity = Math.max(baseQuantity, getMinQuantityForPending, minQuantity);
139
+ }
140
+ return baseQuantity;
141
+ }, [item.quantity, item.price.id, userDid, getMinQuantityForPending, canAdjustQuantity, minQuantity]);
142
+ const [localQuantity, setLocalQuantity] = (0, _react.useState)(initialQuantity);
124
143
  const localQuantityNum = localQuantity || 0;
125
144
  (0, _react.useEffect)(() => {
126
- const initialQuantity = getInitialQuantity();
127
- if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
128
- onQuantityChange(item.price_id, initialQuantity);
145
+ if (initialQuantity && initialQuantity > 0) {
146
+ if (initialQuantity !== localQuantity) {
147
+ setLocalQuantity(initialQuantity);
148
+ }
149
+ if (initialQuantity !== item.quantity) {
150
+ onQuantityChange(item.price_id, initialQuantity);
151
+ }
152
+ if (isCreditProduct && pendingAmount && getMinQuantityForPending) {
153
+ setPayable(initialQuantity >= getMinQuantityForPending);
154
+ } else {
155
+ setPayable(true);
156
+ }
129
157
  }
130
- }, []);
158
+ }, [initialQuantity, isCreditProduct, pendingAmount, getMinQuantityForPending]);
131
159
  const handleQuantityChange = newQuantity => {
132
160
  if (!newQuantity) {
133
161
  setLocalQuantity(void 0);
134
162
  setPayable(false);
135
163
  return;
136
164
  }
137
- setPayable(true);
138
165
  if (newQuantity >= minQuantity && newQuantity <= maxQuantity) {
139
166
  if ((0, _util2.formatQuantityInventory)(item.price, newQuantity, locale)) {
140
167
  return;
141
168
  }
169
+ if (isCreditProduct && pendingAmount && getMinQuantityForPending) {
170
+ if (newQuantity < getMinQuantityForPending) {
171
+ setPayable(false);
172
+ setLocalQuantity(newQuantity);
173
+ onQuantityChange(item.price_id, newQuantity);
174
+ return;
175
+ }
176
+ }
177
+ setPayable(true);
142
178
  setLocalQuantity(newQuantity);
143
179
  onQuantityChange(item.price_id, newQuantity);
144
180
  if (userDid && newQuantity > 0) {
@@ -204,7 +240,7 @@ function ProductItem({
204
240
  }
205
241
  const pendingAmountBN = new _util.BN(pendingAmount || "0");
206
242
  const creditAmountBN = (0, _util.fromTokenToUnit)(creditAmount, creditCurrency?.decimal || 2);
207
- const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new _util.BN(100)).div(creditAmountBN).toNumber() / 100);
243
+ const minQuantityNeeded = getMinQuantityForPending || 0;
208
244
  const currentPurchaseCreditBN = creditAmountBN.mul(new _util.BN(localQuantity || 0));
209
245
  const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
210
246
  if (!new _util.BN(actualAvailable).gt(new _util.BN(0))) {
@@ -216,6 +252,44 @@ function ProductItem({
216
252
  const type = isRecurring ? "recurringEnough" : "oneTimeEnough";
217
253
  return t(getLocaleKey("pending", type), buildPendingParams(pendingAmountBN, actualAvailable));
218
254
  };
255
+ const formatScheduleInfo = () => {
256
+ if (!hasSchedule || !scheduleConfig) return null;
257
+ const totalCredit = creditAmount * (localQuantity || 0);
258
+ const currencySymbol = creditCurrency?.symbol || "Credits";
259
+ const intervalUnit = scheduleConfig.interval_unit;
260
+ const intervalValue = scheduleConfig.interval_value;
261
+ let amountPerGrant;
262
+ if (scheduleConfig.amount_per_grant) {
263
+ amountPerGrant = Number(scheduleConfig.amount_per_grant) * (localQuantity || 1);
264
+ } else {
265
+ const billingIntervalUnit = item.price.recurring?.interval || "month";
266
+ const billingIntervalCount = item.price.recurring?.interval_count || 1;
267
+ const unitToHours = {
268
+ hour: 1,
269
+ day: 24,
270
+ week: 168,
271
+ month: 720
272
+ // ~30 days
273
+ };
274
+ const billingHours = unitToHours[billingIntervalUnit] * billingIntervalCount;
275
+ const scheduleHours = unitToHours[intervalUnit] * intervalValue;
276
+ const grantsPerPeriod = Math.floor(billingHours / scheduleHours);
277
+ amountPerGrant = grantsPerPeriod > 0 ? totalCredit / grantsPerPeriod : totalCredit;
278
+ }
279
+ const formattedAmount = (0, _util2.formatCreditAmount)((0, _util2.formatNumber)(amountPerGrant), currencySymbol);
280
+ const intervalDisplay = intervalValue === 1 ? t(`common.${intervalUnit}`) : ` ${intervalValue} ${t(`common.${intervalUnit}s`)} `;
281
+ const expireWithNext = scheduleConfig.expire_with_next_grant;
282
+ if (expireWithNext) {
283
+ return t("payment.checkout.credit.schedule.withRefresh", {
284
+ amount: formattedAmount,
285
+ interval: intervalDisplay
286
+ });
287
+ }
288
+ return t("payment.checkout.credit.schedule.periodic", {
289
+ amount: formattedAmount,
290
+ interval: intervalDisplay
291
+ });
292
+ };
219
293
  const primaryText = (0, _react.useMemo)(() => {
220
294
  const price = item.upsell_price || item.price || {};
221
295
  const isRecurring = price?.type === "recurring" && price?.recurring;
@@ -450,7 +524,19 @@ function ProductItem({
450
524
  fontSize: "0.875rem"
451
525
  },
452
526
  icon: false,
453
- children: formatCreditInfo()
527
+ children: hasSchedule ? /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
528
+ component: "span",
529
+ sx: {
530
+ fontSize: "inherit"
531
+ },
532
+ children: formatScheduleInfo()
533
+ }) : /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
534
+ component: "span",
535
+ sx: {
536
+ fontSize: "inherit"
537
+ },
538
+ children: formatCreditInfo()
539
+ })
454
540
  }), children]
455
541
  }), canUpsell && !item.upsell_price_id && item.price.upsell?.upsells_to && /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
456
542
  direction: "row",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react",
3
- "version": "1.23.10",
3
+ "version": "1.24.0",
4
4
  "description": "Reusable react components for payment kit v2",
5
5
  "keywords": [
6
6
  "react",
@@ -96,7 +96,7 @@
96
96
  "@babel/core": "^7.27.4",
97
97
  "@babel/preset-env": "^7.27.2",
98
98
  "@babel/preset-react": "^7.27.1",
99
- "@blocklet/payment-types": "1.23.10",
99
+ "@blocklet/payment-types": "1.24.0",
100
100
  "@storybook/addon-essentials": "^7.6.20",
101
101
  "@storybook/addon-interactions": "^7.6.20",
102
102
  "@storybook/addon-links": "^7.6.20",
@@ -127,5 +127,5 @@
127
127
  "vite-plugin-babel": "^1.3.1",
128
128
  "vite-plugin-node-polyfills": "^0.23.0"
129
129
  },
130
- "gitHead": "9d058650e81ae43ffafb3e1253b50b6245d510ac"
130
+ "gitHead": "f3fad0829a1377422d51f9b6445a3502d0e94719"
131
131
  }
@@ -6,7 +6,7 @@
6
6
  /* eslint-disable react/no-unstable-nested-components */
7
7
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
8
8
  import type { Paginated, TCreditTransactionExpanded } from '@blocklet/payment-types';
9
- import { Box, Typography, Grid, Stack, Link, Button } from '@mui/material';
9
+ import { Box, Typography, Grid, Stack, Link, Button, Chip } from '@mui/material';
10
10
  import { useRequest } from 'ahooks';
11
11
  // eslint-disable-next-line import/no-extraneous-dependencies
12
12
  import { useNavigate } from 'react-router-dom';
@@ -181,6 +181,7 @@ const TransactionsTable = React.memo((props: Props) => {
181
181
  customBodyRenderLite: (_: string, index: number) => {
182
182
  const item = data?.list[index] as any;
183
183
  const isGrant = item.activity_type === 'grant';
184
+ const isExpiredGrant = isGrant && item.status === 'expired';
184
185
  const amount = isGrant ? item.amount : item.credit_amount;
185
186
  const currency = item.paymentCurrency || item.currency;
186
187
  const unit = !isGrant && item.meter?.unit ? item.meter.unit : currency?.symbol;
@@ -194,14 +195,34 @@ const TransactionsTable = React.memo((props: Props) => {
194
195
  );
195
196
  }
196
197
 
198
+ const amountNode = (
199
+ <Typography
200
+ sx={{
201
+ color: isGrant ? (isExpiredGrant ? 'text.disabled' : 'success.main') : 'error.main',
202
+ }}>
203
+ {isGrant ? '+' : '-'} {displayAmount}
204
+ </Typography>
205
+ );
206
+
197
207
  return (
198
208
  <Box onClick={(e) => handleTransactionClick(e, item)}>
199
- <Typography
200
- sx={{
201
- color: isGrant ? 'success.main' : 'error.main',
202
- }}>
203
- {isGrant ? '+' : '-'} {displayAmount}
204
- </Typography>
209
+ <Stack direction="row" spacing={1} alignItems="center" justifyContent="flex-end" sx={{ width: '100%' }}>
210
+ {isExpiredGrant && (
211
+ <Chip
212
+ label={t('admin.creditGrants.status.expired')}
213
+ size="small"
214
+ variant="outlined"
215
+ sx={{
216
+ mr: 2,
217
+ height: 18,
218
+ fontSize: '12px',
219
+ color: 'text.disabled',
220
+ borderColor: 'text.disabled',
221
+ }}
222
+ />
223
+ )}
224
+ {amountNode}
225
+ </Stack>
205
226
  </Box>
206
227
  );
207
228
  },
@@ -214,9 +235,15 @@ const TransactionsTable = React.memo((props: Props) => {
214
235
  customBodyRenderLite: (_: string, index: number) => {
215
236
  const item = data?.list[index] as any;
216
237
  const isGrant = item.activity_type === 'grant';
238
+ const isExpiredGrant = isGrant && item.status === 'expired';
217
239
 
218
240
  const grantName = isGrant ? item.name : item.creditGrant.name;
219
241
  const grantId = isGrant ? item.id : item.credit_grant_id;
242
+ const nameNode = (
243
+ <Typography variant="body2" sx={{ cursor: 'pointer', color: isExpiredGrant ? 'text.disabled' : undefined }}>
244
+ {grantName || `Grant ${grantId.slice(-6)}`}
245
+ </Typography>
246
+ );
220
247
  return (
221
248
  <Stack
222
249
  direction="row"
@@ -228,9 +255,7 @@ const TransactionsTable = React.memo((props: Props) => {
228
255
  sx={{
229
256
  alignItems: 'center',
230
257
  }}>
231
- <Typography variant="body2" sx={{ cursor: 'pointer' }}>
232
- {grantName || `Grant ${grantId.slice(-6)}`}
233
- </Typography>
258
+ {nameNode}
234
259
  </Stack>
235
260
  );
236
261
  },
@@ -243,17 +268,18 @@ const TransactionsTable = React.memo((props: Props) => {
243
268
  customBodyRenderLite: (_: string, index: number) => {
244
269
  const item = data?.list[index] as any;
245
270
  const isGrant = item.activity_type === 'grant';
271
+ const isExpiredGrant = isGrant && item.status === 'expired';
246
272
  const description = isGrant
247
273
  ? item.name || item.description || 'Credit Granted'
248
274
  : item.subscription?.description || item.description || `${item.meter_event_name} usage`;
249
275
 
250
- return (
251
- <Box onClick={(e) => handleTransactionClick(e, item)}>
252
- <Typography variant="body2" sx={{ fontWeight: 400 }}>
253
- {description}
254
- </Typography>
255
- </Box>
276
+ const descriptionNode = (
277
+ <Typography variant="body2" sx={{ fontWeight: 400, color: isExpiredGrant ? 'text.disabled' : undefined }}>
278
+ {description}
279
+ </Typography>
256
280
  );
281
+
282
+ return <Box onClick={(e) => handleTransactionClick(e, item)}>{descriptionNode}</Box>;
257
283
  },
258
284
  },
259
285
  },
@@ -276,6 +276,10 @@ export default flat({
276
276
  recurringEnoughWithExpiry:
277
277
  'You have a usage overage of {amount}. This subscription adds {totalAmount} {period} (valid for {duration} {unit}). After covering the overage, your new available balance will be {availableAmount}.',
278
278
  },
279
+ schedule: {
280
+ periodic: 'Grant {amount} every {interval}.',
281
+ withRefresh: 'Grant {amount} every {interval}; unused credits expire with the next grant.',
282
+ },
279
283
  },
280
284
  expired: {
281
285
  title: 'Expired Link',
@@ -309,6 +309,10 @@ export default flat({
309
309
  recurringEnoughWithExpiry:
310
310
  '您有 {amount} 的使用超额。本订阅{period}将增加 {totalAmount}(有效期 {duration} {unit})。扣除超额后,您的新可用余额将为 {availableAmount}。',
311
311
  },
312
+ schedule: {
313
+ periodic: '每{interval}发放 {amount}。',
314
+ withRefresh: '每{interval}发放 {amount},未使用额度将在下次发放时过期。',
315
+ },
312
316
  },
313
317
  emptyItems: {
314
318
  title: '没有任何购买项目',
@@ -18,6 +18,7 @@ import {
18
18
  formatUpsellSaving,
19
19
  formatAmount,
20
20
  formatCreditForCheckout,
21
+ formatCreditAmount,
21
22
  formatBNStr,
22
23
  } from '../libs/util';
23
24
  import ProductCard from './product-card';
@@ -96,13 +97,21 @@ export default function ProductItem({
96
97
  const metered = item.price?.recurring?.usage_type === 'metered' ? t('common.metered') : '';
97
98
  const canUpsell = mode === 'normal' && items.length === 1;
98
99
 
99
- const isCreditProduct = item.price.product?.type === 'credit' && item.price.metadata?.credit_config?.credit_amount;
100
- const creditAmount = isCreditProduct ? Number(item.price.metadata.credit_config.credit_amount) : 0;
101
- const creditCurrency = isCreditProduct
102
- ? findCurrency(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? '')
103
- : null;
100
+ // Check if this is a credit product - be more lenient in detection
101
+ const isCreditProduct = item.price.product?.type === 'credit' && item.price.metadata?.credit_config;
102
+ const creditAmount =
103
+ isCreditProduct && item.price.metadata?.credit_config?.credit_amount
104
+ ? Number(item.price.metadata.credit_config.credit_amount)
105
+ : 0;
106
+ const creditCurrency =
107
+ isCreditProduct && item.price.metadata?.credit_config?.currency_id
108
+ ? findCurrency(settings.paymentMethods, item.price.metadata.credit_config.currency_id)
109
+ : null;
104
110
  const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
105
111
  const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || 'days';
112
+ const scheduleConfig = item.price.metadata?.credit_config?.schedule;
113
+ const hasSchedule =
114
+ scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== 'invoice';
106
115
 
107
116
  const userDid = session?.user?.did;
108
117
 
@@ -127,40 +136,69 @@ export default function ProductItem({
127
136
  }
128
137
  );
129
138
 
130
- // calculate initial quantity: priority URL recommendation > user preference > item.quantity
131
- const getInitialQuantity = (): number | undefined => {
139
+ const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal';
140
+ const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
141
+ const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
142
+ const maxQuantity = quantityAvailable
143
+ ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable)
144
+ : adjustableQuantity.maximum || Infinity;
145
+
146
+ const getMinQuantityForPending = useMemo(() => {
147
+ if (!isCreditProduct || !pendingAmount) return null;
148
+ const pendingAmountBN = new BN(pendingAmount || '0');
149
+ if (!pendingAmountBN.gt(new BN(0))) return null;
150
+ const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2);
151
+ return Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
152
+ }, [isCreditProduct, pendingAmount, creditAmount, creditCurrency?.decimal]);
153
+
154
+ const initialQuantity = useMemo(() => {
132
155
  const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
133
156
  if (urlQuantity && urlQuantity > 0) {
157
+ if (canAdjustQuantity && getMinQuantityForPending) {
158
+ return Math.max(urlQuantity, getMinQuantityForPending, minQuantity);
159
+ }
134
160
  return urlQuantity;
135
161
  }
136
162
 
137
163
  if (userDid) {
138
164
  const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
139
165
  if (preferredQuantity && preferredQuantity > 0) {
166
+ if (canAdjustQuantity && getMinQuantityForPending) {
167
+ return Math.max(preferredQuantity, getMinQuantityForPending, minQuantity);
168
+ }
140
169
  return preferredQuantity;
141
170
  }
142
171
  }
143
172
 
144
- return item.quantity;
145
- };
173
+ let baseQuantity = item.quantity;
146
174
 
147
- const [localQuantity, setLocalQuantity] = useState<number | undefined>(getInitialQuantity());
148
- const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal';
149
- const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
150
- const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
151
- const maxQuantity = quantityAvailable
152
- ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable)
153
- : adjustableQuantity.maximum || Infinity;
175
+ if (canAdjustQuantity && getMinQuantityForPending) {
176
+ baseQuantity = Math.max(baseQuantity, getMinQuantityForPending, minQuantity);
177
+ }
178
+
179
+ return baseQuantity;
180
+ }, [item.quantity, item.price.id, userDid, getMinQuantityForPending, canAdjustQuantity, minQuantity]);
181
+
182
+ const [localQuantity, setLocalQuantity] = useState<number | undefined>(initialQuantity);
154
183
  const localQuantityNum = localQuantity || 0;
155
184
 
156
185
  useEffect(() => {
157
- const initialQuantity = getInitialQuantity();
158
- if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
159
- // need update checkout session
160
- onQuantityChange(item.price_id, initialQuantity);
186
+ if (initialQuantity && initialQuantity > 0) {
187
+ if (initialQuantity !== localQuantity) {
188
+ setLocalQuantity(initialQuantity);
189
+ }
190
+ if (initialQuantity !== item.quantity) {
191
+ onQuantityChange(item.price_id, initialQuantity);
192
+ }
193
+
194
+ if (isCreditProduct && pendingAmount && getMinQuantityForPending) {
195
+ setPayable(initialQuantity >= getMinQuantityForPending);
196
+ } else {
197
+ setPayable(true);
198
+ }
161
199
  }
162
200
  // eslint-disable-next-line react-hooks/exhaustive-deps
163
- }, []);
201
+ }, [initialQuantity, isCreditProduct, pendingAmount, getMinQuantityForPending]);
164
202
 
165
203
  const handleQuantityChange = (newQuantity: number) => {
166
204
  if (!newQuantity) {
@@ -168,11 +206,22 @@ export default function ProductItem({
168
206
  setPayable(false);
169
207
  return;
170
208
  }
171
- setPayable(true);
209
+
172
210
  if (newQuantity >= minQuantity && newQuantity <= maxQuantity) {
173
211
  if (formatQuantityInventory(item.price, newQuantity, locale)) {
174
212
  return;
175
213
  }
214
+
215
+ if (isCreditProduct && pendingAmount && getMinQuantityForPending) {
216
+ if (newQuantity < getMinQuantityForPending) {
217
+ setPayable(false);
218
+ setLocalQuantity(newQuantity);
219
+ onQuantityChange(item.price_id, newQuantity);
220
+ return;
221
+ }
222
+ }
223
+
224
+ setPayable(true);
176
225
  setLocalQuantity(newQuantity);
177
226
  onQuantityChange(item.price_id, newQuantity);
178
227
 
@@ -259,7 +308,7 @@ export default function ProductItem({
259
308
 
260
309
  const pendingAmountBN = new BN(pendingAmount || '0');
261
310
  const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2);
262
- const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
311
+ const minQuantityNeeded = getMinQuantityForPending || 0;
263
312
  const currentPurchaseCreditBN = creditAmountBN.mul(new BN(localQuantity || 0));
264
313
  const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
265
314
 
@@ -278,6 +327,58 @@ export default function ProductItem({
278
327
  return t(getLocaleKey('pending', type), buildPendingParams(pendingAmountBN, actualAvailable));
279
328
  };
280
329
 
330
+ // Format credit schedule info for display
331
+ const formatScheduleInfo = () => {
332
+ if (!hasSchedule || !scheduleConfig) return null;
333
+
334
+ const totalCredit = creditAmount * (localQuantity || 0);
335
+ const currencySymbol = creditCurrency?.symbol || 'Credits';
336
+ const intervalUnit = scheduleConfig.interval_unit;
337
+ const intervalValue = scheduleConfig.interval_value;
338
+
339
+ // Calculate amount per grant
340
+ let amountPerGrant: number;
341
+ if (scheduleConfig.amount_per_grant) {
342
+ amountPerGrant = Number(scheduleConfig.amount_per_grant) * (localQuantity || 1);
343
+ } else {
344
+ // Divide total by period intervals
345
+ const billingIntervalUnit = item.price.recurring?.interval || 'month';
346
+ const billingIntervalCount = item.price.recurring?.interval_count || 1;
347
+
348
+ // Calculate how many schedule intervals fit in billing period
349
+ const unitToHours: Record<string, number> = {
350
+ hour: 1,
351
+ day: 24,
352
+ week: 168,
353
+ month: 720, // ~30 days
354
+ };
355
+ const billingHours = unitToHours[billingIntervalUnit] * billingIntervalCount;
356
+ const scheduleHours = unitToHours[intervalUnit] * intervalValue;
357
+ const grantsPerPeriod = Math.floor(billingHours / scheduleHours);
358
+ amountPerGrant = grantsPerPeriod > 0 ? totalCredit / grantsPerPeriod : totalCredit;
359
+ }
360
+ const formattedAmount = formatCreditAmount(formatNumber(amountPerGrant), currencySymbol);
361
+
362
+ const intervalDisplay =
363
+ intervalValue === 1
364
+ ? t(`common.${intervalUnit}`)
365
+ : ` ${intervalValue} ${t(`common.${intervalUnit}s` as 'common.hours' | 'common.days' | 'common.weeks' | 'common.months')} `;
366
+
367
+ const expireWithNext = scheduleConfig.expire_with_next_grant;
368
+
369
+ if (expireWithNext) {
370
+ return t('payment.checkout.credit.schedule.withRefresh', {
371
+ amount: formattedAmount,
372
+ interval: intervalDisplay,
373
+ });
374
+ }
375
+
376
+ return t('payment.checkout.credit.schedule.periodic', {
377
+ amount: formattedAmount,
378
+ interval: intervalDisplay,
379
+ });
380
+ };
381
+
281
382
  const primaryText = useMemo(() => {
282
383
  const price = item.upsell_price || item.price || {};
283
384
  const isRecurring = price?.type === 'recurring' && price?.recurring;
@@ -494,7 +595,15 @@ export default function ProductItem({
494
595
  {/* Credit 信息展示 */}
495
596
  {isCreditProduct && (
496
597
  <Alert severity="info" sx={{ mt: 1, fontSize: '0.875rem' }} icon={false}>
497
- {formatCreditInfo()}
598
+ {hasSchedule ? (
599
+ <Typography component="span" sx={{ fontSize: 'inherit' }}>
600
+ {formatScheduleInfo()}
601
+ </Typography>
602
+ ) : (
603
+ <Typography component="span" sx={{ fontSize: 'inherit' }}>
604
+ {formatCreditInfo()}
605
+ </Typography>
606
+ )}
498
607
  </Alert>
499
608
  )}
500
609