@blocklet/payment-react 1.27.1 → 1.28.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,5 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from "react";
2
+ import { useState, useEffect, useRef, useCallback } from "react";
3
3
  import AddIcon from "@mui/icons-material/Add";
4
4
  import CheckIcon from "@mui/icons-material/Check";
5
5
  import LocalOfferIcon from "@mui/icons-material/LocalOffer";
@@ -129,6 +129,22 @@ export default function ProductItemCard({
129
129
  useEffect(() => {
130
130
  if (!isEditing) setQtyInput(String(quantity));
131
131
  }, [quantity, isEditing]);
132
+ const debounceRef = useRef(null);
133
+ const debouncedQuantityChange = useCallback(
134
+ (priceId, qty) => {
135
+ if (debounceRef.current) clearTimeout(debounceRef.current);
136
+ debounceRef.current = setTimeout(() => {
137
+ onQuantityChange(priceId, qty);
138
+ }, 400);
139
+ },
140
+ [onQuantityChange]
141
+ );
142
+ useEffect(
143
+ () => () => {
144
+ if (debounceRef.current) clearTimeout(debounceRef.current);
145
+ },
146
+ []
147
+ );
132
148
  const canUpsell = !!item.price?.upsell?.upsells_to;
133
149
  const isUpselled = !!item.upsell_price;
134
150
  const upsellTo = item.price?.upsell?.upsells_to;
@@ -409,8 +425,14 @@ export default function ProductItemCard({
409
425
  IconButton,
410
426
  {
411
427
  size: "small",
412
- onClick: () => onQuantityChange(item.price_id, quantity - 1),
413
- disabled: quantity <= min,
428
+ onClick: () => {
429
+ const cur = parseInt(qtyInput, 10) || quantity;
430
+ const newQty = cur - 1;
431
+ if (newQty < min) return;
432
+ setQtyInput(String(newQty));
433
+ debouncedQuantityChange(item.price_id, newQty);
434
+ },
435
+ disabled: (parseInt(qtyInput, 10) || quantity) <= min,
414
436
  sx: {
415
437
  width: 36,
416
438
  height: 36,
@@ -477,8 +499,14 @@ export default function ProductItemCard({
477
499
  IconButton,
478
500
  {
479
501
  size: "small",
480
- onClick: () => onQuantityChange(item.price_id, quantity + 1),
481
- disabled: max ? quantity >= max : false,
502
+ onClick: () => {
503
+ const cur = parseInt(qtyInput, 10) || quantity;
504
+ const newQty = cur + 1;
505
+ if (max && newQty > max) return;
506
+ setQtyInput(String(newQty));
507
+ debouncedQuantityChange(item.price_id, newQty);
508
+ },
509
+ disabled: max ? (parseInt(qtyInput, 10) || quantity) >= max : false,
482
510
  sx: {
483
511
  width: 36,
484
512
  height: 36,
@@ -153,6 +153,13 @@ export default function CreditTopupPanel() {
153
153
  }, [minQtyForPending]);
154
154
  const maxCredits = maxQuantity * step;
155
155
  const minCredits = minQuantity * step;
156
+ const debounceRef = useRef(null);
157
+ useEffect(
158
+ () => () => {
159
+ if (debounceRef.current) clearTimeout(debounceRef.current);
160
+ },
161
+ []
162
+ );
156
163
  const commitCredits = useCallback(
157
164
  (credits) => {
158
165
  if (credits <= 0) return;
@@ -161,7 +168,12 @@ export default function CreditTopupPanel() {
161
168
  const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs));
162
169
  setLocalQty(clampedPacks);
163
170
  setDesiredCredits(clamped);
164
- if (item) lineItems.updateQuantity(item.price_id, clampedPacks);
171
+ if (item) {
172
+ if (debounceRef.current) clearTimeout(debounceRef.current);
173
+ debounceRef.current = setTimeout(() => {
174
+ lineItems.updateQuantity(item.price_id, clampedPacks);
175
+ }, 400);
176
+ }
165
177
  },
166
178
  [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems]
167
179
  );
@@ -122,31 +122,43 @@ export default function PaymentPanel() {
122
122
  return () => document.removeEventListener("keydown", handleKeyDown);
123
123
  }, [canSubmit, submit.status, handleAction]);
124
124
  const didConnectOpenedRef = useRef(false);
125
+ const didConnectSucceededRef = useRef(false);
126
+ const submitStatusRef = useRef(submit.status);
127
+ useEffect(() => {
128
+ submitStatusRef.current = submit.status;
129
+ }, [submit.status]);
125
130
  useEffect(() => {
126
131
  if (submit.status !== "waiting_did") {
127
132
  didConnectOpenedRef.current = false;
133
+ didConnectSucceededRef.current = false;
128
134
  return;
129
135
  }
130
136
  const ctx = submit.context;
131
137
  if (ctx?.type !== "did_connect" || !connect) return;
132
138
  if (didConnectOpenedRef.current) return;
133
139
  didConnectOpenedRef.current = true;
134
- const didPrefix = `${paymentKitPrefix}/api/did`.replace(/([^:])\/\//g, "$1/");
140
+ const didPrefix = `${paymentKitPrefix || window.location.origin}/api/did`.replace(/([^:])\/\//g, "$1/");
135
141
  connect.open({
136
142
  locale,
137
143
  action: ctx.action,
138
144
  prefix: didPrefix,
139
145
  saveConnect: false,
140
146
  extraParams: ctx.extraParams,
141
- onSuccess: () => {
147
+ onSuccess: async () => {
148
+ didConnectSucceededRef.current = true;
142
149
  connect.close();
150
+ await submit.didConnectComplete();
143
151
  },
144
152
  onClose: () => {
145
153
  connect.close();
146
- submit.reset();
154
+ if (submitStatusRef.current !== "waiting_did") return;
155
+ if (!didConnectSucceededRef.current) {
156
+ submit.reset();
157
+ }
147
158
  },
148
159
  onError: (err) => {
149
160
  console.error("DID Connect error:", err);
161
+ if (submitStatusRef.current !== "waiting_did") return;
150
162
  submit.reset();
151
163
  },
152
164
  messages: {
@@ -365,7 +365,7 @@ function VendorProgressPanel({
365
365
  animation: `${fadeUp} 0.5s ease 0.3s both`
366
366
  },
367
367
  children: [
368
- vendorStatus.vendors.map((vendor, idx) => /* @__PURE__ */ jsx(VendorProgressItemV2, { vendor, t }, vendor.title || `vendor-${idx}`)),
368
+ (vendorStatus.vendors || []).map((vendor, idx) => /* @__PURE__ */ jsx(VendorProgressItemV2, { vendor, t }, vendor.title || `vendor-${idx}`)),
369
369
  vendorStatus.hasFailed && /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 13, fontWeight: 600, color: "warning.main", mt: 1 }, children: t("payment.checkout.vendor.failedMsg") }),
370
370
  vendorStatus.isAllCompleted && pageInfo?.success_message?.[locale] && /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 14, fontWeight: 600, color: "text.primary", mt: 1 }, children: pageInfo.success_message[locale] })
371
371
  ]
@@ -410,11 +410,12 @@ const InvoiceTable = React.memo((props) => {
410
410
  options: {
411
411
  customBodyRenderLite: (val, index) => {
412
412
  const invoice = data?.list[index];
413
- return /* @__PURE__ */ jsx(Box, { onClick: (e) => handleLinkClick(e, invoice), sx: linkStyle, children: formatToDate(
413
+ const periodTooltip = invoice.subscription_id && invoice.period_start && invoice.period_end ? `${t("common.billingPeriod")}: ${formatToDate(invoice.period_start * 1e3, locale, "YYYY-MM-DD HH:mm")} ~ ${formatToDate(invoice.period_end * 1e3, locale, "YYYY-MM-DD HH:mm")}` : "";
414
+ return /* @__PURE__ */ jsx(Tooltip, { title: periodTooltip, arrow: true, placement: "top-start", children: /* @__PURE__ */ jsx(Box, { onClick: (e) => handleLinkClick(e, invoice), sx: linkStyle, children: formatToDate(
414
415
  invoice.created_at,
415
416
  locale,
416
417
  relatedSubscription ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD HH:mm:ss"
417
- ) });
418
+ ) }) });
418
419
  }
419
420
  }
420
421
  },
@@ -700,13 +701,21 @@ const InvoiceList = React.memo((props) => {
700
701
  }
701
702
  ),
702
703
  /* @__PURE__ */ jsx(
703
- Box,
704
+ Tooltip,
704
705
  {
705
- sx: {
706
- flex: 1,
707
- textAlign: "right"
708
- },
709
- children: /* @__PURE__ */ jsx(Typography, { children: formatToDate(invoice.created_at, locale, "HH:mm:ss") })
706
+ title: invoice.subscription_id && invoice.period_start && invoice.period_end ? `${t("common.billingPeriod")}: ${formatToDate(invoice.period_start * 1e3, locale, "YYYY-MM-DD HH:mm")} ~ ${formatToDate(invoice.period_end * 1e3, locale, "YYYY-MM-DD HH:mm")}` : "",
707
+ arrow: true,
708
+ placement: "top-start",
709
+ children: /* @__PURE__ */ jsx(
710
+ Box,
711
+ {
712
+ sx: {
713
+ flex: 1,
714
+ textAlign: "right"
715
+ },
716
+ children: /* @__PURE__ */ jsx(Typography, { children: formatToDate(invoice.created_at, locale, "HH:mm:ss") })
717
+ }
718
+ )
710
719
  }
711
720
  ),
712
721
  !action && /* @__PURE__ */ jsx(
package/es/libs/util.js CHANGED
@@ -1225,6 +1225,7 @@ export function truncateText(text, maxLength, useWidth = false) {
1225
1225
  return `${truncated}...`;
1226
1226
  }
1227
1227
  export function getCustomerAvatar(did, updated_at, imageSize = 48) {
1228
+ if (!did) return "";
1228
1229
  const updated = typeof updated_at === "number" ? updated_at : dayjs(updated_at).unix();
1229
1230
  return `/.well-known/service/user/avatar/${did}?imageFilter=resize&w=${imageSize}&h=${imageSize}&updateAt=${updated || dayjs().unix()}`;
1230
1231
  }
package/es/locales/en.js CHANGED
@@ -7,6 +7,7 @@ export default flat({
7
7
  none: "None",
8
8
  createdAt: "Created At",
9
9
  updatedAt: "Updated At",
10
+ billingPeriod: "Billing Period",
10
11
  resumesAt: "Resume At",
11
12
  actions: "Actions",
12
13
  options: "Options",
package/es/locales/zh.js CHANGED
@@ -7,6 +7,7 @@ export default flat({
7
7
  none: "\u65E0",
8
8
  createdAt: "\u521B\u5EFA\u65F6\u95F4",
9
9
  updatedAt: "\u66F4\u65B0\u65F6\u95F4",
10
+ billingPeriod: "\u8D26\u5355\u5468\u671F",
10
11
  resumesAt: "\u6062\u590D\u65F6\u95F4",
11
12
  actions: "\u64CD\u4F5C",
12
13
  options: "\u9009\u9879",
@@ -126,6 +126,16 @@ function ProductItemCard({
126
126
  (0, _react.useEffect)(() => {
127
127
  if (!isEditing) setQtyInput(String(quantity));
128
128
  }, [quantity, isEditing]);
129
+ const debounceRef = (0, _react.useRef)(null);
130
+ const debouncedQuantityChange = (0, _react.useCallback)((priceId, qty) => {
131
+ if (debounceRef.current) clearTimeout(debounceRef.current);
132
+ debounceRef.current = setTimeout(() => {
133
+ onQuantityChange(priceId, qty);
134
+ }, 400);
135
+ }, [onQuantityChange]);
136
+ (0, _react.useEffect)(() => () => {
137
+ if (debounceRef.current) clearTimeout(debounceRef.current);
138
+ }, []);
129
139
  const canUpsell = !!item.price?.upsell?.upsells_to;
130
140
  const isUpselled = !!item.upsell_price;
131
141
  const upsellTo = item.price?.upsell?.upsells_to;
@@ -554,8 +564,14 @@ function ProductItemCard({
554
564
  children: t("common.quantity")
555
565
  }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.IconButton, {
556
566
  size: "small",
557
- onClick: () => onQuantityChange(item.price_id, quantity - 1),
558
- disabled: quantity <= min,
567
+ onClick: () => {
568
+ const cur = parseInt(qtyInput, 10) || quantity;
569
+ const newQty = cur - 1;
570
+ if (newQty < min) return;
571
+ setQtyInput(String(newQty));
572
+ debouncedQuantityChange(item.price_id, newQty);
573
+ },
574
+ disabled: (parseInt(qtyInput, 10) || quantity) <= min,
559
575
  sx: {
560
576
  width: 36,
561
577
  height: 36,
@@ -624,8 +640,14 @@ function ProductItemCard({
624
640
  }
625
641
  }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.IconButton, {
626
642
  size: "small",
627
- onClick: () => onQuantityChange(item.price_id, quantity + 1),
628
- disabled: max ? quantity >= max : false,
643
+ onClick: () => {
644
+ const cur = parseInt(qtyInput, 10) || quantity;
645
+ const newQty = cur + 1;
646
+ if (max && newQty > max) return;
647
+ setQtyInput(String(newQty));
648
+ debouncedQuantityChange(item.price_id, newQty);
649
+ },
650
+ disabled: max ? (parseInt(qtyInput, 10) || quantity) >= max : false,
629
651
  sx: {
630
652
  width: 36,
631
653
  height: 36,
@@ -181,6 +181,10 @@ function CreditTopupPanel() {
181
181
  }, [minQtyForPending]);
182
182
  const maxCredits = maxQuantity * step;
183
183
  const minCredits = minQuantity * step;
184
+ const debounceRef = (0, _react.useRef)(null);
185
+ (0, _react.useEffect)(() => () => {
186
+ if (debounceRef.current) clearTimeout(debounceRef.current);
187
+ }, []);
184
188
  const commitCredits = (0, _react.useCallback)(credits => {
185
189
  if (credits <= 0) return;
186
190
  const clamped = Math.max(minCredits, Math.min(maxCredits, credits));
@@ -188,7 +192,12 @@ function CreditTopupPanel() {
188
192
  const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs));
189
193
  setLocalQty(clampedPacks);
190
194
  setDesiredCredits(clamped);
191
- if (item) lineItems.updateQuantity(item.price_id, clampedPacks);
195
+ if (item) {
196
+ if (debounceRef.current) clearTimeout(debounceRef.current);
197
+ debounceRef.current = setTimeout(() => {
198
+ lineItems.updateQuantity(item.price_id, clampedPacks);
199
+ }, 400);
200
+ }
192
201
  }, [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems]);
193
202
  const handleStep = (0, _react.useCallback)(delta => {
194
203
  const newCredits = desiredCredits + delta * step;
@@ -125,31 +125,43 @@ function PaymentPanel() {
125
125
  return () => document.removeEventListener("keydown", handleKeyDown);
126
126
  }, [canSubmit, submit.status, handleAction]);
127
127
  const didConnectOpenedRef = (0, _react.useRef)(false);
128
+ const didConnectSucceededRef = (0, _react.useRef)(false);
129
+ const submitStatusRef = (0, _react.useRef)(submit.status);
130
+ (0, _react.useEffect)(() => {
131
+ submitStatusRef.current = submit.status;
132
+ }, [submit.status]);
128
133
  (0, _react.useEffect)(() => {
129
134
  if (submit.status !== "waiting_did") {
130
135
  didConnectOpenedRef.current = false;
136
+ didConnectSucceededRef.current = false;
131
137
  return;
132
138
  }
133
139
  const ctx = submit.context;
134
140
  if (ctx?.type !== "did_connect" || !connect) return;
135
141
  if (didConnectOpenedRef.current) return;
136
142
  didConnectOpenedRef.current = true;
137
- const didPrefix = `${paymentKitPrefix}/api/did`.replace(/([^:])\/\//g, "$1/");
143
+ const didPrefix = `${paymentKitPrefix || window.location.origin}/api/did`.replace(/([^:])\/\//g, "$1/");
138
144
  connect.open({
139
145
  locale,
140
146
  action: ctx.action,
141
147
  prefix: didPrefix,
142
148
  saveConnect: false,
143
149
  extraParams: ctx.extraParams,
144
- onSuccess: () => {
150
+ onSuccess: async () => {
151
+ didConnectSucceededRef.current = true;
145
152
  connect.close();
153
+ await submit.didConnectComplete();
146
154
  },
147
155
  onClose: () => {
148
156
  connect.close();
149
- submit.reset();
157
+ if (submitStatusRef.current !== "waiting_did") return;
158
+ if (!didConnectSucceededRef.current) {
159
+ submit.reset();
160
+ }
150
161
  },
151
162
  onError: err => {
152
163
  console.error("DID Connect error:", err);
164
+ if (submitStatusRef.current !== "waiting_did") return;
153
165
  submit.reset();
154
166
  },
155
167
  messages: {
@@ -424,7 +424,7 @@ function VendorProgressPanel({
424
424
  borderColor: "divider",
425
425
  animation: `${fadeUp} 0.5s ease 0.3s both`
426
426
  },
427
- children: [vendorStatus.vendors.map((vendor, idx) => /* @__PURE__ */(0, _jsxRuntime.jsx)(VendorProgressItemV2, {
427
+ children: [(vendorStatus.vendors || []).map((vendor, idx) => /* @__PURE__ */(0, _jsxRuntime.jsx)(VendorProgressItemV2, {
428
428
  vendor,
429
429
  t
430
430
  }, vendor.title || `vendor-${idx}`)), vendorStatus.hasFailed && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
@@ -479,10 +479,16 @@ const InvoiceTable = _react.default.memo(props => {
479
479
  options: {
480
480
  customBodyRenderLite: (val, index) => {
481
481
  const invoice = data?.list[index];
482
- return /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
483
- onClick: e => handleLinkClick(e, invoice),
484
- sx: linkStyle,
485
- children: (0, _util2.formatToDate)(invoice.created_at, locale, relatedSubscription ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD HH:mm:ss")
482
+ const periodTooltip = invoice.subscription_id && invoice.period_start && invoice.period_end ? `${t("common.billingPeriod")}: ${(0, _util2.formatToDate)(invoice.period_start * 1e3, locale, "YYYY-MM-DD HH:mm")} ~ ${(0, _util2.formatToDate)(invoice.period_end * 1e3, locale, "YYYY-MM-DD HH:mm")}` : "";
483
+ return /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Tooltip, {
484
+ title: periodTooltip,
485
+ arrow: true,
486
+ placement: "top-start",
487
+ children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
488
+ onClick: e => handleLinkClick(e, invoice),
489
+ sx: linkStyle,
490
+ children: (0, _util2.formatToDate)(invoice.created_at, locale, relatedSubscription ? "YYYY-MM-DD HH:mm" : "YYYY-MM-DD HH:mm:ss")
491
+ })
486
492
  });
487
493
  }
488
494
  }
@@ -806,13 +812,18 @@ const InvoiceList = _react.default.memo(props => {
806
812
  },
807
813
  children: rateLine
808
814
  })]
809
- }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
810
- sx: {
811
- flex: 1,
812
- textAlign: "right"
813
- },
814
- children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
815
- children: (0, _util2.formatToDate)(invoice.created_at, locale, "HH:mm:ss")
815
+ }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Tooltip, {
816
+ title: invoice.subscription_id && invoice.period_start && invoice.period_end ? `${t("common.billingPeriod")}: ${(0, _util2.formatToDate)(invoice.period_start * 1e3, locale, "YYYY-MM-DD HH:mm")} ~ ${(0, _util2.formatToDate)(invoice.period_end * 1e3, locale, "YYYY-MM-DD HH:mm")}` : "",
817
+ arrow: true,
818
+ placement: "top-start",
819
+ children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
820
+ sx: {
821
+ flex: 1,
822
+ textAlign: "right"
823
+ },
824
+ children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
825
+ children: (0, _util2.formatToDate)(invoice.created_at, locale, "HH:mm:ss")
826
+ })
816
827
  })
817
828
  }), !action && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
818
829
  className: "invoice-description",
package/lib/libs/util.js CHANGED
@@ -1510,6 +1510,7 @@ function truncateText(text, maxLength, useWidth = false) {
1510
1510
  return `${truncated}...`;
1511
1511
  }
1512
1512
  function getCustomerAvatar(did, updated_at, imageSize = 48) {
1513
+ if (!did) return "";
1513
1514
  const updated = typeof updated_at === "number" ? updated_at : (0, _dayjs.default)(updated_at).unix();
1514
1515
  return `/.well-known/service/user/avatar/${did}?imageFilter=resize&w=${imageSize}&h=${imageSize}&updateAt=${updated || (0, _dayjs.default)().unix()}`;
1515
1516
  }
package/lib/locales/en.js CHANGED
@@ -14,6 +14,7 @@ module.exports = (0, _flat.default)({
14
14
  none: "None",
15
15
  createdAt: "Created At",
16
16
  updatedAt: "Updated At",
17
+ billingPeriod: "Billing Period",
17
18
  resumesAt: "Resume At",
18
19
  actions: "Actions",
19
20
  options: "Options",
package/lib/locales/zh.js CHANGED
@@ -14,6 +14,7 @@ module.exports = (0, _flat.default)({
14
14
  none: "\u65E0",
15
15
  createdAt: "\u521B\u5EFA\u65F6\u95F4",
16
16
  updatedAt: "\u66F4\u65B0\u65F6\u95F4",
17
+ billingPeriod: "\u8D26\u5355\u5468\u671F",
17
18
  resumesAt: "\u6062\u590D\u65F6\u95F4",
18
19
  actions: "\u64CD\u4F5C",
19
20
  options: "\u9009\u9879",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react",
3
- "version": "1.27.1",
3
+ "version": "1.28.0",
4
4
  "description": "Reusable react components for payment kit v2",
5
5
  "keywords": [
6
6
  "react",
@@ -54,19 +54,19 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@arcblock/bridge": "^3.5.1",
58
- "@arcblock/did-connect-react": "^3.5.1",
59
- "@arcblock/react-hooks": "^3.5.1",
60
- "@arcblock/ux": "^3.5.1",
61
- "@arcblock/ws": "^1.28.5",
62
- "@blocklet/payment-react-headless": "1.27.1",
63
- "@blocklet/theme": "^3.5.1",
64
- "@blocklet/ui-react": "^3.5.1",
57
+ "@arcblock/bridge": "^3.5.2",
58
+ "@arcblock/did-connect-react": "^3.5.2",
59
+ "@arcblock/react-hooks": "^3.5.2",
60
+ "@arcblock/ux": "^3.5.2",
61
+ "@arcblock/ws": "^1.30.9",
62
+ "@blocklet/payment-react-headless": "1.28.0",
63
+ "@blocklet/theme": "^3.5.2",
64
+ "@blocklet/ui-react": "^3.5.2",
65
65
  "@mui/icons-material": "^7.1.2",
66
66
  "@mui/lab": "7.0.0-beta.14",
67
67
  "@mui/material": "^7.1.2",
68
68
  "@mui/system": "^7.1.1",
69
- "@ocap/util": "^1.28.5",
69
+ "@ocap/util": "^1.30.9",
70
70
  "@stripe/react-stripe-js": "^2.9.0",
71
71
  "@stripe/stripe-js": "^2.4.0",
72
72
  "@vitejs/plugin-legacy": "^7.0.0",
@@ -97,7 +97,7 @@
97
97
  "@babel/core": "^7.27.4",
98
98
  "@babel/preset-env": "^7.27.2",
99
99
  "@babel/preset-react": "^7.27.1",
100
- "@blocklet/payment-types": "1.27.1",
100
+ "@blocklet/payment-types": "1.28.0",
101
101
  "@storybook/addon-essentials": "^7.6.20",
102
102
  "@storybook/addon-interactions": "^7.6.20",
103
103
  "@storybook/addon-links": "^7.6.20",
@@ -128,5 +128,5 @@
128
128
  "vite-plugin-babel": "^1.3.1",
129
129
  "vite-plugin-node-polyfills": "^0.23.0"
130
130
  },
131
- "gitHead": "7ba8098a5b20b8c62295331186ba7dddc946aa4c"
131
+ "gitHead": "1486b54f913b83fb42323a89cce7503814d0685a"
132
132
  }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import AddIcon from '@mui/icons-material/Add';
3
3
  import CheckIcon from '@mui/icons-material/Check';
4
4
  import LocalOfferIcon from '@mui/icons-material/LocalOffer';
@@ -182,6 +182,24 @@ export default function ProductItemCard({
182
182
  if (!isEditing) setQtyInput(String(quantity));
183
183
  }, [quantity, isEditing]);
184
184
 
185
+ // Debounce quantity API calls — UI updates immediately, API fires after 400ms idle
186
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
187
+ const debouncedQuantityChange = useCallback(
188
+ (priceId: string, qty: number) => {
189
+ if (debounceRef.current) clearTimeout(debounceRef.current);
190
+ debounceRef.current = setTimeout(() => {
191
+ onQuantityChange(priceId, qty);
192
+ }, 400);
193
+ },
194
+ [onQuantityChange]
195
+ );
196
+ useEffect(
197
+ () => () => {
198
+ if (debounceRef.current) clearTimeout(debounceRef.current);
199
+ },
200
+ []
201
+ );
202
+
185
203
  // Upsell info
186
204
  const canUpsell = !!(item.price as any)?.upsell?.upsells_to;
187
205
  const isUpselled = !!(item as any).upsell_price;
@@ -494,8 +512,14 @@ export default function ProductItemCard({
494
512
  </Typography>
495
513
  <IconButton
496
514
  size="small"
497
- onClick={() => onQuantityChange(item.price_id, quantity - 1)}
498
- disabled={quantity <= min}
515
+ onClick={() => {
516
+ const cur = parseInt(qtyInput, 10) || quantity;
517
+ const newQty = cur - 1;
518
+ if (newQty < min) return;
519
+ setQtyInput(String(newQty));
520
+ debouncedQuantityChange(item.price_id, newQty);
521
+ }}
522
+ disabled={(parseInt(qtyInput, 10) || quantity) <= min}
499
523
  sx={{
500
524
  width: 36,
501
525
  height: 36,
@@ -556,8 +580,14 @@ export default function ProductItemCard({
556
580
  />
557
581
  <IconButton
558
582
  size="small"
559
- onClick={() => onQuantityChange(item.price_id, quantity + 1)}
560
- disabled={max ? quantity >= max : false}
583
+ onClick={() => {
584
+ const cur = parseInt(qtyInput, 10) || quantity;
585
+ const newQty = cur + 1;
586
+ if (max && newQty > max) return;
587
+ setQtyInput(String(newQty));
588
+ debouncedQuantityChange(item.price_id, newQty);
589
+ }}
590
+ disabled={max ? (parseInt(qtyInput, 10) || quantity) >= max : false}
561
591
  sx={{
562
592
  width: 36,
563
593
  height: 36,
@@ -204,7 +204,16 @@ export default function CreditTopupPanel() {
204
204
  const maxCredits = maxQuantity * step;
205
205
  const minCredits = minQuantity * step;
206
206
 
207
- // Commit desired credits compute packs update API
207
+ // Debounce API calls UI updates immediately, API fires after 400ms idle
208
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
209
+ useEffect(
210
+ () => () => {
211
+ if (debounceRef.current) clearTimeout(debounceRef.current);
212
+ },
213
+ []
214
+ );
215
+
216
+ // Commit desired credits → compute packs → update API (debounced)
208
217
  const commitCredits = useCallback(
209
218
  (credits: number) => {
210
219
  if (credits <= 0) return;
@@ -214,7 +223,12 @@ export default function CreditTopupPanel() {
214
223
  const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs));
215
224
  setLocalQty(clampedPacks);
216
225
  setDesiredCredits(clamped);
217
- if (item) lineItems.updateQuantity(item.price_id, clampedPacks);
226
+ if (item) {
227
+ if (debounceRef.current) clearTimeout(debounceRef.current);
228
+ debounceRef.current = setTimeout(() => {
229
+ lineItems.updateQuantity(item.price_id, clampedPacks);
230
+ }, 400);
231
+ }
218
232
  },
219
233
  [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems]
220
234
  );
@@ -151,9 +151,18 @@ export default function PaymentPanel() {
151
151
 
152
152
  // DID Connect
153
153
  const didConnectOpenedRef = useRef(false);
154
+ const didConnectSucceededRef = useRef(false);
155
+ // Track latest submit.status for use inside DID Connect SDK callbacks (onClose/onError),
156
+ // which fire asynchronously and may run after status has already advanced past waiting_did.
157
+ // Reading submit.status directly would capture a stale closure.
158
+ const submitStatusRef = useRef(submit.status);
159
+ useEffect(() => {
160
+ submitStatusRef.current = submit.status;
161
+ }, [submit.status]);
154
162
  useEffect(() => {
155
163
  if (submit.status !== 'waiting_did') {
156
164
  didConnectOpenedRef.current = false;
165
+ didConnectSucceededRef.current = false;
157
166
  return;
158
167
  }
159
168
  const ctx = submit.context;
@@ -161,22 +170,36 @@ export default function PaymentPanel() {
161
170
  if (didConnectOpenedRef.current) return;
162
171
  didConnectOpenedRef.current = true;
163
172
 
164
- const didPrefix = `${paymentKitPrefix}/api/did`.replace(/([^:])\/\//g, '$1/');
173
+ // Use absolute URL for DID Connect prefix — DID Connect SDK needs full URL
174
+ // for status polling. paymentKitPrefix may be relative ('/' or ''), which can
175
+ // cause polling to fail. getPrefix() from util.ts returns the full origin URL.
176
+ const didPrefix = `${paymentKitPrefix || window.location.origin}/api/did`.replace(/([^:])\/\//g, '$1/');
165
177
  connect.open({
166
178
  locale: locale as any,
167
179
  action: ctx.action,
168
180
  prefix: didPrefix,
169
181
  saveConnect: false,
170
182
  extraParams: ctx.extraParams,
171
- onSuccess: () => {
183
+ onSuccess: async () => {
184
+ didConnectSucceededRef.current = true;
172
185
  connect.close();
186
+ await submit.didConnectComplete();
173
187
  },
174
188
  onClose: () => {
175
189
  connect.close();
176
- submit.reset();
190
+ // If submit has already advanced past waiting_did (e.g. didConnectComplete is
191
+ // polling, or completion/failure resolved), the modal close is a follow-up to a
192
+ // successful flow — do not reset. Without this guard, the effect re-run after
193
+ // setStatus('submitting') clears didConnectSucceededRef, and a late onClose call
194
+ // would incorrectly tear down the in-flight polling.
195
+ if (submitStatusRef.current !== 'waiting_did') return;
196
+ if (!didConnectSucceededRef.current) {
197
+ submit.reset();
198
+ }
177
199
  },
178
200
  onError: (err: any) => {
179
201
  console.error('DID Connect error:', err);
202
+ if (submitStatusRef.current !== 'waiting_did') return;
180
203
  submit.reset();
181
204
  },
182
205
  messages: {
@@ -185,6 +208,8 @@ export default function PaymentPanel() {
185
208
  confirm: t('payment.checkout.connectModal.confirm'),
186
209
  } as any,
187
210
  });
211
+
212
+ // No cleanup needed — connect modal manages its own lifecycle.
188
213
  }, [submit.status, submit.context, connect, locale, t, buttonLabel]); // eslint-disable-line react-hooks/exhaustive-deps
189
214
 
190
215
  // Active payment type
@@ -471,7 +471,7 @@ function VendorProgressPanel({
471
471
  borderColor: 'divider',
472
472
  animation: `${fadeUp} 0.5s ease 0.3s both`,
473
473
  }}>
474
- {vendorStatus.vendors.map((vendor, idx) => (
474
+ {(vendorStatus.vendors || []).map((vendor, idx) => (
475
475
  <VendorProgressItemV2 key={vendor.title || `vendor-${idx}`} vendor={vendor} t={t} />
476
476
  ))}
477
477
  {vendorStatus.hasFailed && (
@@ -510,15 +510,21 @@ const InvoiceTable = React.memo((props: Props & { onPay: (invoiceId: string) =>
510
510
  options: {
511
511
  customBodyRenderLite: (val: string, index: number) => {
512
512
  const invoice = data?.list[index] as TInvoiceExpanded;
513
+ const periodTooltip =
514
+ invoice.subscription_id && invoice.period_start && invoice.period_end
515
+ ? `${t('common.billingPeriod')}: ${formatToDate(invoice.period_start * 1000, locale, 'YYYY-MM-DD HH:mm')} ~ ${formatToDate(invoice.period_end * 1000, locale, 'YYYY-MM-DD HH:mm')}`
516
+ : '';
513
517
 
514
518
  return (
515
- <Box onClick={(e) => handleLinkClick(e, invoice)} sx={linkStyle}>
516
- {formatToDate(
517
- invoice.created_at,
518
- locale,
519
- relatedSubscription ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss'
520
- )}
521
- </Box>
519
+ <Tooltip title={periodTooltip} arrow placement="top-start">
520
+ <Box onClick={(e) => handleLinkClick(e, invoice)} sx={linkStyle}>
521
+ {formatToDate(
522
+ invoice.created_at,
523
+ locale,
524
+ relatedSubscription ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss'
525
+ )}
526
+ </Box>
527
+ </Tooltip>
522
528
  );
523
529
  },
524
530
  },
@@ -825,13 +831,22 @@ const InvoiceList = React.memo((props: Props & { onPay: (invoiceId: string) => v
825
831
  </Typography>
826
832
  )}
827
833
  </Box>
828
- <Box
829
- sx={{
830
- flex: 1,
831
- textAlign: 'right',
832
- }}>
833
- <Typography>{formatToDate(invoice.created_at, locale, 'HH:mm:ss')}</Typography>
834
- </Box>
834
+ <Tooltip
835
+ title={
836
+ invoice.subscription_id && invoice.period_start && invoice.period_end
837
+ ? `${t('common.billingPeriod')}: ${formatToDate(invoice.period_start * 1000, locale, 'YYYY-MM-DD HH:mm')} ~ ${formatToDate(invoice.period_end * 1000, locale, 'YYYY-MM-DD HH:mm')}`
838
+ : ''
839
+ }
840
+ arrow
841
+ placement="top-start">
842
+ <Box
843
+ sx={{
844
+ flex: 1,
845
+ textAlign: 'right',
846
+ }}>
847
+ <Typography>{formatToDate(invoice.created_at, locale, 'HH:mm:ss')}</Typography>
848
+ </Box>
849
+ </Tooltip>
835
850
  {!action && (
836
851
  <Box
837
852
  className="invoice-description"
@@ -15,7 +15,7 @@ const getSocketHost = () => new URL(window.location.href).host;
15
15
  * @return {*}
16
16
  */
17
17
  export function useSubscription(channel: string) {
18
- const socket = useRef<typeof WsClient>(null);
18
+ const socket = useRef<InstanceType<typeof WsClient> | null>(null);
19
19
  const subscription = useRef<any>(null);
20
20
 
21
21
  useEffect(() => {
package/src/libs/util.ts CHANGED
@@ -1654,6 +1654,7 @@ export function getCustomerAvatar(
1654
1654
  updated_at: string | number | undefined,
1655
1655
  imageSize: number = 48
1656
1656
  ): string {
1657
+ if (!did) return '';
1657
1658
  const updated = typeof updated_at === 'number' ? updated_at : dayjs(updated_at).unix();
1658
1659
  return `/.well-known/service/user/avatar/${did}?imageFilter=resize&w=${imageSize}&h=${imageSize}&updateAt=${updated || dayjs().unix()}`;
1659
1660
  }
@@ -9,6 +9,7 @@ export default flat({
9
9
  none: 'None',
10
10
  createdAt: 'Created At',
11
11
  updatedAt: 'Updated At',
12
+ billingPeriod: 'Billing Period',
12
13
  resumesAt: 'Resume At',
13
14
  actions: 'Actions',
14
15
  options: 'Options',
@@ -9,6 +9,7 @@ export default flat({
9
9
  none: '无',
10
10
  createdAt: '创建时间',
11
11
  updatedAt: '更新时间',
12
+ billingPeriod: '账单周期',
12
13
  resumesAt: '恢复时间',
13
14
  actions: '操作',
14
15
  options: '选项',