@blocklet/payment-react
Version:
Reusable react components for payment kit v2
443 lines (442 loc) • 16.3 kB
JavaScript
import { jsx, jsxs } from "react/jsx-runtime";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import { Avatar, Box, Card, CardActionArea, Grid, Stack, TextField, Typography, IconButton } from "@mui/material";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import { useSetState } from "ahooks";
import { useEffect, useRef } from "react";
import { formatAmountPrecisionLimit } from "../libs/util.js";
import { usePaymentContext } from "../contexts/payment.js";
import { usePreventWheel } from "../hooks/scroll.js";
import { useTabNavigation } from "../hooks/keyboard.js";
const DONATION_PRESET_KEY_BASE = "payment-donation-preset";
const DONATION_CUSTOM_AMOUNT_KEY_BASE = "payment-donation-custom-amount";
const formatAmount = (amount) => {
const str = String(amount);
if (!str || str === "0") return str;
const num = parseFloat(str);
if (Number.isNaN(num)) return str;
return num.toString();
};
export default function ProductDonation({
item,
settings,
onChange,
currency
}) {
const { t, locale } = useLocaleContext();
const { setPayable, session } = usePaymentContext();
usePreventWheel();
const presets = (settings?.amount?.presets || []).map(formatAmount);
const getUserStorageKey = (base) => {
const userDid = session?.user?.did;
return userDid ? `${base}:${userDid}` : base;
};
const getSavedCustomAmount = () => {
try {
const saved = localStorage.getItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE)) || "";
return saved ? formatAmount(saved) : "";
} catch (e) {
console.warn("Failed to access localStorage", e);
return "";
}
};
const getDefaultPreset = () => {
try {
const savedPreset = localStorage.getItem(getUserStorageKey(DONATION_PRESET_KEY_BASE));
if (savedPreset) {
if (presets.includes(formatAmount(savedPreset))) {
return formatAmount(savedPreset);
}
if (savedPreset === "custom" && supportCustom) {
return "custom";
}
}
} catch (e) {
console.warn("Failed to access localStorage", e);
}
if (presets.length > 0) {
const middleIndex = Math.floor(presets.length / 2);
return presets[middleIndex];
}
return "0";
};
const supportPreset = presets.length > 0;
const supportCustom = !!settings?.amount?.custom;
const defaultPreset = getDefaultPreset();
const defaultCustomAmount = defaultPreset === "custom" ? getSavedCustomAmount() : "";
const [state, setState] = useSetState({
selected: defaultPreset === "custom" ? "" : defaultPreset,
input: defaultCustomAmount,
custom: !supportPreset || defaultPreset === "custom",
error: "",
animating: false
});
const customInputRef = useRef(null);
const containerRef = useRef(null);
const handleSelect = (amount) => {
setPayable(true);
setState({ selected: formatAmount(amount), custom: false, error: "" });
onChange({ priceId: item.price_id, amount: formatAmount(amount) });
localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), formatAmount(amount));
};
const handleCustomSelect = () => {
setState({
custom: true,
selected: "",
animating: true
});
const hasPresets = presets.length > 0;
let sortedPresets = [];
if (hasPresets) {
sortedPresets = [...presets].map((p) => parseFloat(p)).sort((a, b) => a - b);
}
const minPreset = hasPresets ? sortedPresets[sortedPresets.length - 1] : 1;
let maxPreset = hasPresets ? sortedPresets[sortedPresets.length - 1] * 5 : 100;
const systemMax = settings.amount.maximum ? parseFloat(settings.amount.maximum) : Infinity;
maxPreset = Math.min(maxPreset, systemMax);
const detectPrecision = () => {
let maxPrecision = 2;
if (!hasPresets) return 0;
const allIntegers = presets.every((preset) => {
const num = parseFloat(preset);
return num === Math.floor(num);
});
if (allIntegers) return 0;
presets.forEach((preset) => {
const decimalPart = preset.toString().split(".")[1];
if (decimalPart) {
maxPrecision = Math.max(maxPrecision, decimalPart.length);
}
});
return maxPrecision;
};
const precision = detectPrecision();
let randomAmount;
if (precision === 0) {
randomAmount = (Math.round(Math.random() * (maxPreset - minPreset) + minPreset) || 1).toString();
} else {
randomAmount = (Math.random() * (maxPreset - minPreset) + minPreset).toFixed(precision);
}
const startValue = state.input ? parseFloat(state.input) : 0;
const targetValue = parseFloat(randomAmount);
const difference = targetValue - startValue;
const startTime = Date.now();
const duration = 800;
const updateCounter = () => {
const currentTime = Date.now();
const elapsed = currentTime - startTime;
if (elapsed < duration) {
const progress = elapsed / duration;
const intermediateValue = startValue + difference * progress;
const currentValue = precision === 0 ? Math.floor(intermediateValue).toString() : intermediateValue.toFixed(precision);
setState({ input: currentValue });
requestAnimationFrame(updateCounter);
} else {
setState({
input: randomAmount,
animating: false,
error: ""
});
onChange({ priceId: item.price_id, amount: formatAmount(randomAmount) });
setPayable(true);
localStorage.setItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE), formatAmount(randomAmount));
setTimeout(() => {
customInputRef.current?.focus();
}, 200);
}
};
requestAnimationFrame(updateCounter);
localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), "custom");
};
const handleTabSelect = (selectedItem) => {
if (selectedItem === "custom") {
handleCustomSelect();
} else {
handleSelect(selectedItem);
}
};
const { handleKeyDown } = useTabNavigation(presets, handleTabSelect, {
includeCustom: supportCustom,
currentValue: state.custom ? void 0 : state.selected,
isCustomSelected: state.custom,
enabled: true,
selector: ".tab-navigable-card button",
containerRef
});
useEffect(() => {
const currentPreset = getDefaultPreset();
const isCustom = currentPreset === "custom";
setState({
selected: isCustom ? "" : currentPreset,
custom: !supportPreset || currentPreset === "custom",
input: defaultCustomAmount,
error: ""
});
if (!isCustom) {
onChange({ priceId: item.price_id, amount: currentPreset });
setPayable(true);
} else if (defaultCustomAmount) {
onChange({ priceId: item.price_id, amount: defaultCustomAmount });
setPayable(true);
} else {
setPayable(false);
}
}, [settings.amount.preset, settings.amount.presets, supportPreset]);
useEffect(() => {
if (containerRef.current) {
containerRef.current.focus();
}
if (state.custom) {
setTimeout(() => {
customInputRef.current?.focus();
}, 0);
}
}, [state.custom]);
const handleInput = (event) => {
const { value } = event.target;
const min = parseFloat(settings.amount.minimum || "0");
const max = settings.amount.maximum ? parseFloat(settings.amount.maximum) : Infinity;
const precision = currency?.maximum_precision || 6;
if (formatAmountPrecisionLimit(value, locale)) {
setState({ input: value, error: formatAmountPrecisionLimit(value, locale, precision) });
setPayable(false);
return;
}
if (value < min || value > max) {
setState({ input: value, error: t("payment.checkout.donation.between", { min, max }) });
setPayable(false);
return;
}
setPayable(true);
setState({ error: "", input: value });
onChange({ priceId: item.price_id, amount: formatAmount(value) });
localStorage.setItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE), formatAmount(value));
};
return /* @__PURE__ */ jsxs(
Box,
{
ref: containerRef,
onKeyDown: handleKeyDown,
tabIndex: 0,
sx: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 1.5,
outline: "none"
},
children: [
supportPreset && /* @__PURE__ */ jsxs(Grid, { container: true, spacing: 2, children: [
presets.map((amount) => /* @__PURE__ */ jsx(
Grid,
{
size: {
xs: 6,
sm: 3
},
children: /* @__PURE__ */ jsx(
Card,
{
variant: "outlined",
className: "tab-navigable-card",
sx: {
minWidth: 115,
textAlign: "center",
transition: "all 0.3s",
cursor: "pointer",
"&:hover": {
transform: "translateY(-4px)",
boxShadow: 3
},
".MuiCardActionArea-focusHighlight": {
backgroundColor: "transparent"
},
height: "42px",
...formatAmount(state.selected) === formatAmount(amount) && !state.custom ? { borderColor: "primary.main", borderWidth: 1 } : {}
},
children: /* @__PURE__ */ jsx(
CardActionArea,
{
onClick: () => handleSelect(amount),
tabIndex: 0,
"aria-selected": formatAmount(state.selected) === formatAmount(amount) && !state.custom,
children: /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 0.5,
sx: {
alignItems: "center",
justifyContent: "center",
py: 1.5,
px: 1.5
},
children: [
/* @__PURE__ */ jsx(Avatar, { src: currency?.logo, sx: { width: 16, height: 16, mr: 0.5 }, alt: currency?.symbol }),
/* @__PURE__ */ jsx(
Typography,
{
component: "strong",
variant: "h3",
sx: {
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
fontWeight: 400
},
children: amount
}
),
/* @__PURE__ */ jsx(
Typography,
{
sx: {
lineHeight: 1,
fontSize: 14,
color: "text.secondary"
},
children: currency?.symbol
}
)
]
}
)
}
)
}
)
},
amount
)),
supportCustom && /* @__PURE__ */ jsx(
Grid,
{
size: {
xs: 6,
sm: 3
},
children: /* @__PURE__ */ jsx(
Card,
{
variant: "outlined",
className: "tab-navigable-card",
sx: {
textAlign: "center",
transition: "all 0.3s",
cursor: "pointer",
"&:hover": {
transform: "translateY(-4px)",
boxShadow: 3
},
".MuiCardActionArea-focusHighlight": {
backgroundColor: "transparent"
},
height: "42px",
...state.custom ? { borderColor: "primary.main", borderWidth: 1 } : {}
},
children: /* @__PURE__ */ jsx(CardActionArea, { onClick: handleCustomSelect, tabIndex: 0, "aria-selected": state.custom, children: /* @__PURE__ */ jsx(
Stack,
{
direction: "row",
spacing: 0.5,
sx: {
alignItems: "center",
justifyContent: "center",
py: 1.5,
px: 1.5
},
children: /* @__PURE__ */ jsx(
Typography,
{
variant: "h3",
sx: {
lineHeight: 1,
fontWeight: 400
},
children: t("common.custom")
}
)
}
) })
}
)
},
"custom"
)
] }),
state.custom && /* @__PURE__ */ jsx(
TextField,
{
type: "number",
value: state.input,
onChange: handleInput,
margin: "none",
fullWidth: true,
error: !!state.error,
helperText: state.error,
inputRef: customInputRef,
sx: {
mt: defaultPreset !== "0" ? 0 : 1,
"& .MuiInputBase-root": {
transition: "all 0.3s ease"
},
"& input[type=number]": {
MozAppearance: "textfield"
},
"& input[type=number]::-webkit-outer-spin-button": {
WebkitAppearance: "none",
margin: 0
},
"& input[type=number]::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0
},
"& input": {
transition: "all 0.25s ease"
}
},
slotProps: {
input: {
endAdornment: /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 0.5,
sx: {
alignItems: "center",
ml: 1
},
children: [
/* @__PURE__ */ jsx(
IconButton,
{
size: "small",
onClick: handleCustomSelect,
disabled: state.animating,
sx: {
mr: 0.5,
opacity: state.animating ? 0.5 : 1,
transition: "all 0.2s ease",
"&:hover": {
transform: "scale(1.2)",
transition: "transform 0.3s ease"
}
},
"aria-label": t("common.random"),
children: /* @__PURE__ */ jsx(AutoAwesomeIcon, { fontSize: "small" })
}
),
/* @__PURE__ */ jsx(Avatar, { src: currency?.logo, sx: { width: 16, height: 16 }, alt: currency?.symbol }),
/* @__PURE__ */ jsx(Typography, { children: currency?.symbol })
]
}
),
autoComplete: "off"
}
},
autoComplete: "off"
}
)
]
}
);
}